Compare commits
26 Commits
reduce-mem
...
rhythm
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b4b96e65a | |||
| 26f65eed10 | |||
| 9be88be1f4 | |||
| 10d19a5e58 | |||
| a2ad5d244e | |||
| dc1a6ff5c3 | |||
| 7c06da08b4 | |||
| 3f31780deb | |||
| 24d981886a | |||
| f88f52c4ee | |||
| fbf8bd94c6 | |||
| acd028846c | |||
| ed625e75fc | |||
| b60dcc0e68 | |||
| 909d589609 | |||
| 330f5e6ceb | |||
| 87dacd869b | |||
| 64f467d6ac | |||
| 84cafe2387 | |||
| 8bb89a5f4b | |||
| 499bc7a643 | |||
| 3f670fa9f7 | |||
| b5029bde88 | |||
| 4bcd618073 | |||
| 6ada2aba30 | |||
| c5965aa1f7 |
@ -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.
|
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
|
## Installation
|
||||||
|
|
||||||
Download or git clone this repository into your Arduino > libraries folder.
|
Download or git clone this repository into your Arduino > libraries folder.
|
||||||
|
|||||||
435
firmware/Comparator/Comparator.ino
Normal file
435
firmware/Comparator/Comparator.ino
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
417
firmware/Euclidean/Euclidean.ino
Normal file
417
firmware/Euclidean/Euclidean.ino
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
/**
|
||||||
|
* @file Gravity.ino
|
||||||
|
* @author Adam Wonak (https://github.com/awonak/)
|
||||||
|
* @brief Alt firmware version of Gravity by Sitka Instruments.
|
||||||
|
* @version v2.0.1beta1 - February 2026 awonak
|
||||||
|
* @date 2026-02-21
|
||||||
|
*
|
||||||
|
* @copyright MIT - (c) 2026 - Adam Wonak - adam.wonak@gmail.com
|
||||||
|
*
|
||||||
|
* This version of Gravity firmware is a full rewrite that leverages the
|
||||||
|
* libGravity hardware abstraction library. The goal of this project was to
|
||||||
|
* create an open source friendly version of the firmware that makes it easy
|
||||||
|
* for users/developers to modify and create their own original alt firmware
|
||||||
|
* implementations.
|
||||||
|
*
|
||||||
|
* The libGravity library represents wrappers around the
|
||||||
|
* hardware peripherials to make it easy to interact with and add behavior
|
||||||
|
* to them. The library tries not to make any assumptions about what the
|
||||||
|
* firmware can or should do.
|
||||||
|
*
|
||||||
|
* The Gravity firmware is a slightly different implementation of the original
|
||||||
|
* firmware. There are a few notable changes; the internal clock operates at
|
||||||
|
* 96 PPQN instead of the original 24 PPQN, which allows for more granular
|
||||||
|
* quantization of features like duty cycle (pulse width) or offset.
|
||||||
|
* Additionally, this firmware replaces the sequencer with a Euclidean Rhythm
|
||||||
|
* generator.
|
||||||
|
*
|
||||||
|
* ENCODER:
|
||||||
|
* Press: change between selecting a parameter and editing the parameter.
|
||||||
|
* Hold & Rotate: change current selected output channel.
|
||||||
|
*
|
||||||
|
* BTN1:
|
||||||
|
* Play/pause - start or stop the internal clock.
|
||||||
|
*
|
||||||
|
* BTN2:
|
||||||
|
* Shift - hold and rotate encoder to change current selected output
|
||||||
|
* channel.
|
||||||
|
*
|
||||||
|
* EXT:
|
||||||
|
* External clock input. When Gravity is set to INTERNAL or MIDI clock
|
||||||
|
* source, this input is used to reset clocks.
|
||||||
|
*
|
||||||
|
* CV1:
|
||||||
|
* External analog input used to provide modulation to any channel
|
||||||
|
* parameter.
|
||||||
|
*
|
||||||
|
* CV2:
|
||||||
|
* External analog input used to provide modulation to any channel
|
||||||
|
* parameter.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <libGravity.h>
|
||||||
|
|
||||||
|
#include "app_state.h"
|
||||||
|
#include "channel.h"
|
||||||
|
#include "display.h"
|
||||||
|
#include "save_state.h"
|
||||||
|
|
||||||
|
AppState app;
|
||||||
|
StateManager stateManager;
|
||||||
|
|
||||||
|
//
|
||||||
|
// Arduino setup and loop.
|
||||||
|
//
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
// Start Gravity.
|
||||||
|
gravity.Init();
|
||||||
|
|
||||||
|
// Show bootsplash when initializing firmware.
|
||||||
|
Bootsplash();
|
||||||
|
delay(2000);
|
||||||
|
|
||||||
|
// Initialize the state manager. This will load settings from EEPROM
|
||||||
|
stateManager.initialize(app);
|
||||||
|
InitGravity(app);
|
||||||
|
|
||||||
|
// Clock handlers.
|
||||||
|
gravity.clock.AttachIntHandler(HandleIntClockTick);
|
||||||
|
gravity.clock.AttachExtHandler(HandleExtClockTick);
|
||||||
|
|
||||||
|
// Encoder rotate and press handlers.
|
||||||
|
gravity.encoder.AttachPressHandler(HandleEncoderPressed);
|
||||||
|
gravity.encoder.AttachRotateHandler(HandleRotate);
|
||||||
|
gravity.encoder.AttachPressRotateHandler(HandlePressedRotate);
|
||||||
|
|
||||||
|
// Button press handlers.
|
||||||
|
gravity.play_button.AttachPressHandler(HandlePlayPressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// Process change in state of inputs and outputs.
|
||||||
|
gravity.Process();
|
||||||
|
|
||||||
|
// Read CVs and call the update function for each channel.
|
||||||
|
int cv1 = gravity.cv1.Read();
|
||||||
|
int cv2 = gravity.cv2.Read();
|
||||||
|
|
||||||
|
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
||||||
|
auto &ch = app.channel[i];
|
||||||
|
// Only apply CV to the channel when the current channel has cv
|
||||||
|
// mod configured.
|
||||||
|
if (ch.isCvModActive()) {
|
||||||
|
ch.applyCvMod(cv1, cv2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clock Run
|
||||||
|
if (app.cv_run == 1 || app.cv_run == 2) {
|
||||||
|
auto &cv = app.cv_run == 1 ? gravity.cv1 : gravity.cv2;
|
||||||
|
int val = cv.Read();
|
||||||
|
if (val > AnalogInput::GATE_THRESHOLD && gravity.clock.IsPaused()) {
|
||||||
|
gravity.clock.Start();
|
||||||
|
app.refresh_screen = true;
|
||||||
|
} else if (val < AnalogInput::GATE_THRESHOLD && !gravity.clock.IsPaused()) {
|
||||||
|
gravity.clock.Stop();
|
||||||
|
ResetOutputs();
|
||||||
|
app.refresh_screen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clock Reset
|
||||||
|
if ((app.cv_reset == 1 &&
|
||||||
|
gravity.cv1.IsRisingEdge(AnalogInput::GATE_THRESHOLD)) ||
|
||||||
|
(app.cv_reset == 2 &&
|
||||||
|
gravity.cv2.IsRisingEdge(AnalogInput::GATE_THRESHOLD))) {
|
||||||
|
gravity.clock.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dirty state eligible to be saved.
|
||||||
|
stateManager.update(app);
|
||||||
|
|
||||||
|
if (app.refresh_screen) {
|
||||||
|
UpdateDisplay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Firmware handlers for clocks.
|
||||||
|
//
|
||||||
|
|
||||||
|
void HandleIntClockTick(uint32_t tick) {
|
||||||
|
bool refresh = false;
|
||||||
|
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
||||||
|
app.channel[i].processClockTick(tick, gravity.outputs[i]);
|
||||||
|
|
||||||
|
if (app.channel[i].isCvModActive()) {
|
||||||
|
refresh = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulse Out gate
|
||||||
|
if (app.selected_pulse != Clock::PULSE_NONE) {
|
||||||
|
int clock_index;
|
||||||
|
switch (app.selected_pulse) {
|
||||||
|
case Clock::PULSE_PPQN_24:
|
||||||
|
clock_index = PULSE_PPQN_24_CLOCK_MOD_INDEX;
|
||||||
|
break;
|
||||||
|
case Clock::PULSE_PPQN_4:
|
||||||
|
clock_index = PULSE_PPQN_4_CLOCK_MOD_INDEX;
|
||||||
|
break;
|
||||||
|
case Clock::PULSE_PPQN_1:
|
||||||
|
clock_index = PULSE_PPQN_1_CLOCK_MOD_INDEX;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint16_t pulse_high_ticks =
|
||||||
|
pgm_read_word_near(&CLOCK_MOD_PULSES[clock_index]);
|
||||||
|
const uint32_t pulse_low_ticks = tick + max((pulse_high_ticks / 2), 1L);
|
||||||
|
|
||||||
|
if (tick % pulse_high_ticks == 0) {
|
||||||
|
gravity.pulse.High();
|
||||||
|
} else if (pulse_low_ticks % pulse_high_ticks == 0) {
|
||||||
|
gravity.pulse.Low();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app.editing_param) {
|
||||||
|
app.refresh_screen |= refresh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void HandleExtClockTick() {
|
||||||
|
switch (app.selected_source) {
|
||||||
|
case Clock::SOURCE_INTERNAL:
|
||||||
|
case Clock::SOURCE_EXTERNAL_MIDI:
|
||||||
|
// Use EXT as Reset when not used for clock source.
|
||||||
|
ResetOutputs();
|
||||||
|
gravity.clock.Reset();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Register EXT cv clock tick.
|
||||||
|
gravity.clock.Tick();
|
||||||
|
}
|
||||||
|
app.refresh_screen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// UI handlers for encoder and buttons.
|
||||||
|
//
|
||||||
|
|
||||||
|
void HandlePlayPressed() {
|
||||||
|
// Check if SHIFT is pressed to mute all/current channel.
|
||||||
|
if (gravity.shift_button.On()) {
|
||||||
|
if (app.selected_channel == 0) {
|
||||||
|
// Mute all channels
|
||||||
|
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
||||||
|
app.channel[i].toggleMute();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mute selected channel
|
||||||
|
auto &ch = GetSelectedChannel();
|
||||||
|
ch.toggleMute();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gravity.clock.IsPaused() ? gravity.clock.Start() : gravity.clock.Stop();
|
||||||
|
ResetOutputs();
|
||||||
|
app.refresh_screen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HandleEncoderPressed() {
|
||||||
|
// Check if leaving editing mode should apply a selection.
|
||||||
|
if (app.editing_param) {
|
||||||
|
if (app.selected_channel == 0) { // main page
|
||||||
|
// TODO: rewrite as switch
|
||||||
|
if (app.selected_param == PARAM_MAIN_ENCODER_DIR) {
|
||||||
|
app.encoder_reversed = app.selected_sub_param == 1;
|
||||||
|
gravity.encoder.SetReverseDirection(app.encoder_reversed);
|
||||||
|
}
|
||||||
|
if (app.selected_param == PARAM_MAIN_ROTATE_DISP) {
|
||||||
|
app.rotate_display = app.selected_sub_param == 1;
|
||||||
|
gravity.display.setFlipMode(app.rotate_display ? 1 : 0);
|
||||||
|
}
|
||||||
|
if (app.selected_param == PARAM_MAIN_SAVE_DATA) {
|
||||||
|
if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) {
|
||||||
|
app.selected_save_slot = app.selected_sub_param;
|
||||||
|
stateManager.saveData(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (app.selected_param == PARAM_MAIN_LOAD_DATA) {
|
||||||
|
if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) {
|
||||||
|
app.selected_save_slot = app.selected_sub_param;
|
||||||
|
// Load pattern data into app state.
|
||||||
|
stateManager.loadData(app, app.selected_save_slot);
|
||||||
|
// Load global performance settings if they have changed.
|
||||||
|
if (gravity.clock.Tempo() != app.tempo) {
|
||||||
|
gravity.clock.SetTempo(app.tempo);
|
||||||
|
}
|
||||||
|
// Load global settings only if clock is not active.
|
||||||
|
if (gravity.clock.IsPaused()) {
|
||||||
|
InitGravity(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (app.selected_param == PARAM_MAIN_RESET_STATE) {
|
||||||
|
if (app.selected_sub_param == 0) { // Reset
|
||||||
|
stateManager.reset(app);
|
||||||
|
InitGravity(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (app.selected_param == PARAM_MAIN_FACTORY_RESET) {
|
||||||
|
if (app.selected_sub_param == 0) { // Erase
|
||||||
|
// Show bootsplash during slow erase operation.
|
||||||
|
Bootsplash();
|
||||||
|
stateManager.factoryReset(app);
|
||||||
|
InitGravity(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only mark dirty and reset selected_sub_param when leaving editing mode.
|
||||||
|
stateManager.markDirty();
|
||||||
|
app.selected_sub_param = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.editing_param = !app.editing_param;
|
||||||
|
app.refresh_screen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HandleRotate(int val) {
|
||||||
|
// Shift & Rotate check
|
||||||
|
if (gravity.shift_button.On()) {
|
||||||
|
HandlePressedRotate(val);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app.editing_param) {
|
||||||
|
// Navigation Mode
|
||||||
|
const int max_param =
|
||||||
|
(app.selected_channel == 0) ? PARAM_MAIN_LAST : PARAM_CH_LAST;
|
||||||
|
updateSelection(app.selected_param, val, max_param);
|
||||||
|
} else {
|
||||||
|
// Editing Mode
|
||||||
|
if (app.selected_channel == 0) {
|
||||||
|
editMainParameter(val);
|
||||||
|
} else {
|
||||||
|
editChannelParameter(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.refresh_screen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HandlePressedRotate(int val) {
|
||||||
|
updateSelection(app.selected_channel, val, Gravity::OUTPUT_COUNT + 1);
|
||||||
|
app.selected_param = 0;
|
||||||
|
stateManager.markDirty();
|
||||||
|
app.refresh_screen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void editMainParameter(int val) {
|
||||||
|
switch (static_cast<ParamsMainPage>(app.selected_param)) {
|
||||||
|
case PARAM_MAIN_TEMPO:
|
||||||
|
if (gravity.clock.ExternalSource()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
gravity.clock.SetTempo(gravity.clock.Tempo() + val);
|
||||||
|
app.tempo = gravity.clock.Tempo();
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_RUN:
|
||||||
|
updateSelection(app.selected_sub_param, val, 3);
|
||||||
|
app.cv_run = app.selected_sub_param;
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_RESET:
|
||||||
|
updateSelection(app.selected_sub_param, val, 3);
|
||||||
|
app.cv_reset = app.selected_sub_param;
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_SOURCE: {
|
||||||
|
byte source = static_cast<int>(app.selected_source);
|
||||||
|
updateSelection(source, val, Clock::SOURCE_LAST);
|
||||||
|
app.selected_source = static_cast<Clock::Source>(source);
|
||||||
|
gravity.clock.SetSource(app.selected_source);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PARAM_MAIN_PULSE: {
|
||||||
|
byte pulse = static_cast<int>(app.selected_pulse);
|
||||||
|
updateSelection(pulse, val, Clock::PULSE_LAST);
|
||||||
|
app.selected_pulse = static_cast<Clock::Pulse>(pulse);
|
||||||
|
if (app.selected_pulse == Clock::PULSE_NONE) {
|
||||||
|
gravity.pulse.Low();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// These changes are applied upon encoder button press.
|
||||||
|
case PARAM_MAIN_ENCODER_DIR:
|
||||||
|
updateSelection(app.selected_sub_param, val, 2);
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_ROTATE_DISP:
|
||||||
|
updateSelection(app.selected_sub_param, val, 2);
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_SAVE_DATA:
|
||||||
|
case PARAM_MAIN_LOAD_DATA:
|
||||||
|
updateSelection(app.selected_sub_param, val,
|
||||||
|
StateManager::MAX_SAVE_SLOTS + 1);
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_RESET_STATE:
|
||||||
|
updateSelection(app.selected_sub_param, val, 2);
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_FACTORY_RESET:
|
||||||
|
updateSelection(app.selected_sub_param, val, 2);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void editChannelParameter(int val) {
|
||||||
|
auto &ch = GetSelectedChannel();
|
||||||
|
switch (app.selected_param) {
|
||||||
|
case PARAM_CH_MOD:
|
||||||
|
ch.setClockMod(ch.getClockModIndex() + val);
|
||||||
|
break;
|
||||||
|
case PARAM_CH_EUC_STEPS:
|
||||||
|
ch.setSteps(ch.getSteps() + val);
|
||||||
|
break;
|
||||||
|
case PARAM_CH_EUC_HITS:
|
||||||
|
ch.setHits(ch.getHits() + val);
|
||||||
|
break;
|
||||||
|
case PARAM_CH_CV1_DEST: {
|
||||||
|
byte dest = static_cast<int>(ch.getCv1Dest());
|
||||||
|
updateSelection(dest, val, CV_DEST_LAST);
|
||||||
|
ch.setCv1Dest(static_cast<CvDestination>(dest));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PARAM_CH_CV2_DEST: {
|
||||||
|
byte dest = static_cast<int>(ch.getCv2Dest());
|
||||||
|
updateSelection(dest, val, CV_DEST_LAST);
|
||||||
|
ch.setCv2Dest(static_cast<CvDestination>(dest));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changes the param by the value provided.
|
||||||
|
void updateSelection(byte ¶m, int change, int maxValue) {
|
||||||
|
// Do not apply acceleration if max value is less than 25.
|
||||||
|
if (maxValue < 25) {
|
||||||
|
change = change > 0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
param = constrain(param + change, 0, maxValue - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// App Helper functions.
|
||||||
|
//
|
||||||
|
|
||||||
|
void InitGravity(AppState &app) {
|
||||||
|
gravity.clock.SetTempo(app.tempo);
|
||||||
|
gravity.clock.SetSource(app.selected_source);
|
||||||
|
gravity.encoder.SetReverseDirection(app.encoder_reversed);
|
||||||
|
gravity.display.setFlipMode(app.rotate_display ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ResetOutputs() {
|
||||||
|
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
||||||
|
gravity.outputs[i].Low();
|
||||||
|
}
|
||||||
|
}
|
||||||
44
firmware/Euclidean/app_state.h
Normal file
44
firmware/Euclidean/app_state.h
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* @file app_state.h
|
||||||
|
* @author Adam Wonak (https://github.com/awonak/)
|
||||||
|
* @brief Alt firmware version of Gravity by Sitka Instruments.
|
||||||
|
* @version 2.0.1
|
||||||
|
* @date 2025-07-04
|
||||||
|
*
|
||||||
|
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef APP_STATE_H
|
||||||
|
#define APP_STATE_H
|
||||||
|
|
||||||
|
#include <libGravity.h>
|
||||||
|
|
||||||
|
#include "channel.h"
|
||||||
|
|
||||||
|
// Global state for settings and app behavior.
|
||||||
|
struct AppState {
|
||||||
|
int tempo = Clock::DEFAULT_TEMPO;
|
||||||
|
Channel channel[Gravity::OUTPUT_COUNT];
|
||||||
|
byte selected_param = 0;
|
||||||
|
byte selected_sub_param = 0; // Temporary value for editing params.
|
||||||
|
byte selected_channel = 0; // 0=tempo, 1-6=output channel
|
||||||
|
byte selected_swing = 0;
|
||||||
|
byte selected_save_slot = 0; // The currently active save slot.
|
||||||
|
Clock::Source selected_source = Clock::SOURCE_INTERNAL;
|
||||||
|
Clock::Pulse selected_pulse = Clock::PULSE_PPQN_24;
|
||||||
|
byte cv_run = 0;
|
||||||
|
byte cv_reset = 0;
|
||||||
|
bool editing_param = false;
|
||||||
|
bool encoder_reversed = false;
|
||||||
|
bool rotate_display = false;
|
||||||
|
bool refresh_screen = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
extern AppState app;
|
||||||
|
|
||||||
|
static Channel &GetSelectedChannel() {
|
||||||
|
return app.channel[app.selected_channel - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // APP_STATE_H
|
||||||
247
firmware/Euclidean/channel.h
Normal file
247
firmware/Euclidean/channel.h
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* @file channel.h
|
||||||
|
* @author Adam Wonak (https://github.com/awonak/)
|
||||||
|
* @brief Alt firmware version of Gravity by Sitka Instruments.
|
||||||
|
* @version 2.0.1
|
||||||
|
* @date 2025-07-04
|
||||||
|
*
|
||||||
|
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef CHANNEL_H
|
||||||
|
#define CHANNEL_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <libGravity.h>
|
||||||
|
|
||||||
|
#include "euclidean.h"
|
||||||
|
|
||||||
|
// Enums for CV Mod destination
|
||||||
|
enum CvDestination : uint8_t {
|
||||||
|
CV_DEST_NONE,
|
||||||
|
CV_DEST_MOD,
|
||||||
|
CV_DEST_EUC_STEPS,
|
||||||
|
CV_DEST_EUC_HITS,
|
||||||
|
CV_DEST_LAST,
|
||||||
|
};
|
||||||
|
|
||||||
|
static const byte MOD_CHOICE_SIZE = 25;
|
||||||
|
|
||||||
|
// Negative numbers are multipliers, positive are divisors.
|
||||||
|
static const int CLOCK_MOD[MOD_CHOICE_SIZE] PROGMEM = {
|
||||||
|
// Divisors
|
||||||
|
128, 64, 32, 24, 16, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2,
|
||||||
|
// Internal Clock Unity (quarter note)
|
||||||
|
1,
|
||||||
|
// Multipliers
|
||||||
|
-2, -3, -4, -6, -8, -12, -16, -24};
|
||||||
|
|
||||||
|
// This represents the number of clock pulses for a 96 PPQN clock source
|
||||||
|
// that match the above div/mult mods.
|
||||||
|
static const int CLOCK_MOD_PULSES[MOD_CHOICE_SIZE] PROGMEM = {
|
||||||
|
// Divisor Pulses (96 * X)
|
||||||
|
12288, 6144, 3072, 2304, 1536, 1152, 1056, 960, 864, 768, 672, 576, 480,
|
||||||
|
384, 288, 192,
|
||||||
|
// Internal Clock Pulses
|
||||||
|
96,
|
||||||
|
// Multiplier Pulses (96 / X)
|
||||||
|
48, 32, 24, 16, 12, 8, 6, 4};
|
||||||
|
|
||||||
|
static const byte DEFAULT_CLOCK_MOD_INDEX = 16; // x1 or 96 PPQN.
|
||||||
|
|
||||||
|
static const byte PULSE_PPQN_24_CLOCK_MOD_INDEX = MOD_CHOICE_SIZE - 1;
|
||||||
|
static const byte PULSE_PPQN_4_CLOCK_MOD_INDEX = MOD_CHOICE_SIZE - 6;
|
||||||
|
static const byte PULSE_PPQN_1_CLOCK_MOD_INDEX = MOD_CHOICE_SIZE - 9;
|
||||||
|
|
||||||
|
class Channel {
|
||||||
|
public:
|
||||||
|
Channel() { Init(); }
|
||||||
|
|
||||||
|
void Init() {
|
||||||
|
// Reset base values to their defaults
|
||||||
|
base_clock_mod_index = DEFAULT_CLOCK_MOD_INDEX;
|
||||||
|
base_euc_steps = 1;
|
||||||
|
base_euc_hits = 1;
|
||||||
|
|
||||||
|
cvmod_clock_mod_index = base_clock_mod_index;
|
||||||
|
|
||||||
|
cv1_dest = CV_DEST_NONE;
|
||||||
|
cv2_dest = CV_DEST_NONE;
|
||||||
|
|
||||||
|
pattern.Init(DEFAULT_PATTERN);
|
||||||
|
|
||||||
|
// Calcule the clock mod pulses on init.
|
||||||
|
_recalculatePulses();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setters (Set the BASE value)
|
||||||
|
|
||||||
|
void setClockMod(int index) {
|
||||||
|
base_clock_mod_index = constrain(index, 0, MOD_CHOICE_SIZE - 1);
|
||||||
|
if (!isCvModActive()) {
|
||||||
|
cvmod_clock_mod_index = base_clock_mod_index;
|
||||||
|
_recalculatePulses();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Euclidean
|
||||||
|
void setSteps(int val) {
|
||||||
|
base_euc_steps = constrain(val, 1, MAX_PATTERN_LEN);
|
||||||
|
if (cv1_dest != CV_DEST_EUC_STEPS && cv2_dest != CV_DEST_EUC_STEPS) {
|
||||||
|
pattern.SetSteps(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void setHits(int val) {
|
||||||
|
base_euc_hits = constrain(val, 1, base_euc_steps);
|
||||||
|
if (cv1_dest != CV_DEST_EUC_HITS && cv2_dest != CV_DEST_EUC_HITS) {
|
||||||
|
pattern.SetHits(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCv1Dest(CvDestination dest) { cv1_dest = dest; }
|
||||||
|
void setCv2Dest(CvDestination dest) { cv2_dest = dest; }
|
||||||
|
CvDestination getCv1Dest() const { return cv1_dest; }
|
||||||
|
CvDestination getCv2Dest() const { return cv2_dest; }
|
||||||
|
|
||||||
|
// Getters (Get the BASE value for editing or cv modded value for display)
|
||||||
|
|
||||||
|
int getClockMod(bool withCvMod = false) const {
|
||||||
|
return pgm_read_word_near(&CLOCK_MOD[getClockModIndex(withCvMod)]);
|
||||||
|
}
|
||||||
|
int getClockModIndex(bool withCvMod = false) const {
|
||||||
|
return withCvMod ? cvmod_clock_mod_index : base_clock_mod_index;
|
||||||
|
}
|
||||||
|
bool isCvModActive() const {
|
||||||
|
return cv1_dest != CV_DEST_NONE || cv2_dest != CV_DEST_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte getSteps(bool withCvMod = false) const {
|
||||||
|
return withCvMod ? pattern.GetSteps() : base_euc_steps;
|
||||||
|
}
|
||||||
|
byte getHits(bool withCvMod = false) const {
|
||||||
|
return withCvMod ? pattern.GetHits() : base_euc_hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleMute() { mute = !mute; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Processes a clock tick and determines if the output should be high
|
||||||
|
* or low. Note: this method is called from an ISR and must be kept as simple
|
||||||
|
* as possible.
|
||||||
|
* @param tick The current clock tick count.
|
||||||
|
* @param output The output object to be modified.
|
||||||
|
*/
|
||||||
|
void processClockTick(uint32_t tick, DigitalOutput &output) {
|
||||||
|
// Mute check
|
||||||
|
if (mute) {
|
||||||
|
output.Low();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint16_t mod_pulses =
|
||||||
|
pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]);
|
||||||
|
|
||||||
|
// Euclidian rhythm cycle check
|
||||||
|
if (!output.On()) {
|
||||||
|
// Step check
|
||||||
|
if (tick % mod_pulses == 0) {
|
||||||
|
bool hit = true;
|
||||||
|
// Euclidean rhythm hit check
|
||||||
|
switch (pattern.NextStep()) {
|
||||||
|
case Pattern::REST:
|
||||||
|
hit = false;
|
||||||
|
break;
|
||||||
|
case Pattern::HIT:
|
||||||
|
hit &= true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (hit) {
|
||||||
|
output.High();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output low check. Half pulse width.
|
||||||
|
const uint32_t duty_cycle_end_tick = tick + _duty_pulses;
|
||||||
|
if (duty_cycle_end_tick % mod_pulses == 0) {
|
||||||
|
output.Low();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @brief Calculate and store cv modded values using bipolar mapping.
|
||||||
|
* Default to base value if not the current CV destination.
|
||||||
|
*
|
||||||
|
* @param cv1_val analog input reading for cv1
|
||||||
|
* @param cv2_val analog input reading for cv2
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
void applyCvMod(int cv1_val, int cv2_val) {
|
||||||
|
// Note: This is optimized for cpu performance. This method is called
|
||||||
|
// from the main loop and stores the cv mod values. This reduces CPU
|
||||||
|
// cycles inside the internal clock interrupt, which is preferrable.
|
||||||
|
// However, if RAM usage grows too much, we have an opportunity to
|
||||||
|
// refactor this to store just the CV read values, and calculate the
|
||||||
|
// cv mod value per channel inside the getter methods by passing cv
|
||||||
|
// values. This would reduce RAM usage, but would introduce a
|
||||||
|
// significant CPU cost, which may have undesirable performance issues.
|
||||||
|
if (!isCvModActive()) {
|
||||||
|
cvmod_clock_mod_index = base_clock_mod_index;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int dest_mod = _calculateMod(CV_DEST_MOD, cv1_val, cv2_val,
|
||||||
|
-(MOD_CHOICE_SIZE / 2), MOD_CHOICE_SIZE / 2);
|
||||||
|
cvmod_clock_mod_index = constrain(base_clock_mod_index + dest_mod, 0, 100);
|
||||||
|
|
||||||
|
int step_mod =
|
||||||
|
_calculateMod(CV_DEST_EUC_STEPS, cv1_val, cv2_val, 0, MAX_PATTERN_LEN);
|
||||||
|
pattern.SetSteps(base_euc_steps + step_mod);
|
||||||
|
|
||||||
|
int hit_mod = _calculateMod(CV_DEST_EUC_HITS, cv1_val, cv2_val, 0,
|
||||||
|
pattern.GetSteps());
|
||||||
|
pattern.SetHits(base_euc_hits + hit_mod);
|
||||||
|
|
||||||
|
// After all cvmod values are updated, recalculate clock pulse modifiers.
|
||||||
|
_recalculatePulses();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
int _calculateMod(CvDestination dest, int cv1_val, int cv2_val, int min_range,
|
||||||
|
int max_range) {
|
||||||
|
int mod1 =
|
||||||
|
(cv1_dest == dest) ? map(cv1_val, -512, 512, min_range, max_range) : 0;
|
||||||
|
int mod2 =
|
||||||
|
(cv2_dest == dest) ? map(cv2_val, -512, 512, min_range, max_range) : 0;
|
||||||
|
return mod1 + mod2;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recalculatePulses() {
|
||||||
|
const uint16_t mod_pulses =
|
||||||
|
pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]);
|
||||||
|
_duty_pulses = max((long)(mod_pulses / 2L), 1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-settable base values.
|
||||||
|
byte base_clock_mod_index;
|
||||||
|
byte base_euc_steps;
|
||||||
|
byte base_euc_hits;
|
||||||
|
|
||||||
|
// Base value with cv mod applied.
|
||||||
|
byte cvmod_clock_mod_index;
|
||||||
|
|
||||||
|
// CV mod configuration
|
||||||
|
CvDestination cv1_dest;
|
||||||
|
CvDestination cv2_dest;
|
||||||
|
|
||||||
|
// Euclidean pattern
|
||||||
|
Pattern pattern;
|
||||||
|
|
||||||
|
// Mute channel flag
|
||||||
|
bool mute;
|
||||||
|
|
||||||
|
// Pre-calculated pulse values for ISR performance
|
||||||
|
uint16_t _duty_pulses;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // CHANNEL_H
|
||||||
519
firmware/Euclidean/display.h
Normal file
519
firmware/Euclidean/display.h
Normal file
@ -0,0 +1,519 @@
|
|||||||
|
/**
|
||||||
|
* @file display.h
|
||||||
|
* @author Adam Wonak (https://github.com/awonak/)
|
||||||
|
* @brief Alt firmware version of Gravity by Sitka Instruments.
|
||||||
|
* @version 2.0.1
|
||||||
|
* @date 2025-07-04
|
||||||
|
*
|
||||||
|
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef DISPLAY_H
|
||||||
|
#define DISPLAY_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
#include "app_state.h"
|
||||||
|
#include "save_state.h"
|
||||||
|
|
||||||
|
//
|
||||||
|
// UI Display functions for drawing the UI to the OLED display.
|
||||||
|
//
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Font: velvetscreen.bdf 9pt
|
||||||
|
* https://stncrn.github.io/u8g2-unifont-helper/
|
||||||
|
* "%/0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
*/
|
||||||
|
const uint8_t TEXT_FONT[437] U8G2_FONT_SECTION("velvetscreen") PROGMEM =
|
||||||
|
"\64\0\2\2\3\3\2\3\4\5\5\0\0\5\0\5\0\0\221\0\0\1\230 \4\200\134%\11\255tT"
|
||||||
|
"R\271RI(\6\252\334T\31)\7\252\134bJ\12+\7\233\345\322J\0,\5\221T\4-\5\213"
|
||||||
|
"f\6.\5\211T\2/"
|
||||||
|
"\6\244\354c\33\60\10\254\354T\64\223\2\61\7\353\354\222\254\6\62\11\254l"
|
||||||
|
"\66J*"
|
||||||
|
"\217\0\63\11\254l\66J\32\215\4\64\10\254l\242\34\272\0\65\11\254l\206\336h"
|
||||||
|
"$\0\66"
|
||||||
|
"\11\254\354T^\61)\0\67\10\254lF\216u\4\70\11\254\354TL*&"
|
||||||
|
"\5\71\11\254\354TL;"
|
||||||
|
")\0:\6\231UR\0A\10\254\354T\34S\6B\11\254lV\34)\216\4C\11\254\354T\324\61"
|
||||||
|
")\0D\10\254lV\64G\2E\10\254l\206\36z\4F\10\254l\206^\71\3G\11\254\354TN"
|
||||||
|
"\63)"
|
||||||
|
"\0H\10\254l\242\34S\6I\6\251T\206\0J\10\254\354k\231\24\0K\11\254l\242J\62"
|
||||||
|
"\225\1L\7\254lr{\4M\11\255t\362ZI\353\0N\11\255t\362TI\356\0O\10\254\354T"
|
||||||
|
"\64\223\2P\11\254lV\34)"
|
||||||
|
"g\0Q\10\254\354T\264b\12R\10\254lV\34\251\31S\11\254\354"
|
||||||
|
"FF\32\215\4T\7\253dVl\1U\10\254l\242\63)\0V\11\255t\262Ne\312\21W\12\255"
|
||||||
|
"t\262J*\251.\0X\11\254l\242L*\312\0Y\12\255tr\252\63\312(\2Z\7\253df*"
|
||||||
|
"\7p\10\255\364V\266\323\2q\7\255\364\216\257\5r\10\253d\242\32*"
|
||||||
|
"\2t\6\255t\376#w\11"
|
||||||
|
"\255\364V\245FN\13x\6\233dR\7\0\0\0\4\377\377\0";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Font: STK-L.bdf 36pt
|
||||||
|
* https://stncrn.github.io/u8g2-unifont-helper/
|
||||||
|
* "%/0123456789ABCDEFILNORSTUVXx"
|
||||||
|
*/
|
||||||
|
const uint8_t LARGE_FONT[766] U8G2_FONT_SECTION("stk-l") =
|
||||||
|
"\35\0\4\4\4\5\3\1\6\20\30\0\0\27\0\0\0\1\77\0\0\2\341%'\17;\226\261\245FL"
|
||||||
|
"\64B\214\30\22\223\220)"
|
||||||
|
"Bj\10Q\232\214\42R\206\310\210\21d\304\30\32a\254\304\270!\0/\14"
|
||||||
|
"\272\272\275\311H\321g\343\306\1\60\37|\373\35CJT\20:"
|
||||||
|
"fW\207\320\210\60\42\304\204\30D\247"
|
||||||
|
"\214\331\354\20\11%"
|
||||||
|
"\212\314\0\61\24z\275\245a\244\12\231\71\63b\214\220q\363\377(E\6\62\33|"
|
||||||
|
"\373\35ShT\20:fl\344\14\211\231\301\306T\71\202#g\371\340\201\1\63\34|"
|
||||||
|
"\373\35ShT"
|
||||||
|
"\20:fl\344@r\264\263\222\344,\215\35\42\241\6\225\31\0\64 "
|
||||||
|
"|\373-!\203\206\214!\62\204"
|
||||||
|
"\314\220A#\10\215\30\65b\324\210Q\306\354\354\1\213\225\363\1\65\32|"
|
||||||
|
"\373\15\25[\214\234/\10)"
|
||||||
|
"Y\61j\350\310Y\32;DB\15*\63\0\66\33}\33\236SiV\14;gt^\230Y\302\202\324"
|
||||||
|
"\71\273;EbM\252\63\0\67\23|\373\205\25\17R\316\207\344\350p\312\201#"
|
||||||
|
"\347\35\0\70 |\373"
|
||||||
|
"\35ShT\20:f\331!\22D\310 "
|
||||||
|
":\205\206\10\11B\307\354\354\20\11\65\250\314\0\71\32|\373"
|
||||||
|
"\35ShT\20:fg\207H,Q\223r\276\30DB\15*\63\0A\26}\33\246r\247\322P\62"
|
||||||
|
"j\310\250\21\343\354\335\203\357\354w\3B$}"
|
||||||
|
"\33\206Dj\226\214\42\61l\304\260\21\303F\14\33\61"
|
||||||
|
"\212\304\222MF\221\30v\316\236=\10\301b\11\0C\27}"
|
||||||
|
"\33\236Si\226\20Bft\376O\211\215"
|
||||||
|
" Db\215\42$\0D\33}\33\206Dj\226\214\32\62l\304\260\21\343\354\177vl\304("
|
||||||
|
"\22K\324"
|
||||||
|
"$\2E\22|\373\205\17R\316KD\30\215\234_>x`\0F\20|"
|
||||||
|
"\373\205\17R\316\227i\262\31"
|
||||||
|
"\71\377\22\0I\7s\333\204\77HL\15{\333\205\201\363\377\77|\360`\0N$}"
|
||||||
|
"\33\6\201\346\314"
|
||||||
|
"\35;\206\12U\242D&\306\230\30cd\210\221!fF\230\31a(+\314\256\63\67\0O\26}"
|
||||||
|
"\33"
|
||||||
|
"\236Si\226\214\32\61\316\376\277\33\61j\310\232Tg\0R\61\216;\6Ek\230\14#"
|
||||||
|
"\61n\304\270"
|
||||||
|
"\21\343F\214\33\61n\304\60\22\243\210\60Q\224j\310\260\61\243\306\20\232"
|
||||||
|
"\325\230QD\206\221\30\67b"
|
||||||
|
"\334\301\1S\42\216;\236c\211\226\220\42\61n\304\270\21c\307R\232,["
|
||||||
|
"\262\203\307\216\65h\16\25"
|
||||||
|
"\21&\253\320\0T\15}\33\206\17R\15\235\377\377\25\0U\21|"
|
||||||
|
"\373\205a\366\377\237\215\30\64D\15"
|
||||||
|
"*\63\0V\26\177\371\205\221\366\377\313\21\343\206\220\42C\25\11r'"
|
||||||
|
"\313\16\3X)~;\206\201\6"
|
||||||
|
"\217\221\30\66\204\20\31\42\244\206\14Cg\320$Q\222\6\315!"
|
||||||
|
"\33\62\212\10\31BD\206\215 v\320"
|
||||||
|
"\302\1x\24\312\272\205A\206\216\220@c\212\224\31$"
|
||||||
|
"S\14\262h\0\0\0\0\4\377\377\0";
|
||||||
|
|
||||||
|
#define play_icon_width 14
|
||||||
|
#define play_icon_height 14
|
||||||
|
static const unsigned char play_icon[28] PROGMEM = {
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x7C, 0x00, 0xFC, 0x00,
|
||||||
|
0xFC, 0x03, 0xFC, 0x0F, 0xFC, 0x0F, 0xFC, 0x03, 0xFC, 0x00,
|
||||||
|
0x7C, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||||
|
static const unsigned char pause_icon[28] PROGMEM = {
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E,
|
||||||
|
0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E,
|
||||||
|
0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x00, 0x00};
|
||||||
|
|
||||||
|
// Constants for screen layout and fonts
|
||||||
|
constexpr uint8_t SCREEN_CENTER_X = 32;
|
||||||
|
constexpr uint8_t MAIN_TEXT_Y = 26;
|
||||||
|
constexpr uint8_t SUB_TEXT_Y = 40;
|
||||||
|
constexpr uint8_t VISIBLE_MENU_ITEMS = 3;
|
||||||
|
constexpr uint8_t MENU_ITEM_HEIGHT = 14;
|
||||||
|
constexpr uint8_t MENU_BOX_PADDING = 4;
|
||||||
|
constexpr uint8_t MENU_BOX_WIDTH = 64;
|
||||||
|
constexpr uint8_t CHANNEL_BOXES_Y = 50;
|
||||||
|
constexpr uint8_t CHANNEL_BOX_WIDTH = 18;
|
||||||
|
constexpr uint8_t CHANNEL_BOX_HEIGHT = 14;
|
||||||
|
|
||||||
|
// Menu items for editing global parameters.
|
||||||
|
enum ParamsMainPage : uint8_t {
|
||||||
|
PARAM_MAIN_TEMPO,
|
||||||
|
PARAM_MAIN_RUN,
|
||||||
|
PARAM_MAIN_RESET,
|
||||||
|
PARAM_MAIN_SOURCE,
|
||||||
|
PARAM_MAIN_PULSE,
|
||||||
|
PARAM_MAIN_ENCODER_DIR,
|
||||||
|
PARAM_MAIN_ROTATE_DISP,
|
||||||
|
PARAM_MAIN_SAVE_DATA,
|
||||||
|
PARAM_MAIN_LOAD_DATA,
|
||||||
|
PARAM_MAIN_RESET_STATE,
|
||||||
|
PARAM_MAIN_FACTORY_RESET,
|
||||||
|
PARAM_MAIN_LAST,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Menu items for editing channel parameters.
|
||||||
|
enum ParamsChannelPage : uint8_t {
|
||||||
|
PARAM_CH_MOD,
|
||||||
|
PARAM_CH_EUC_STEPS,
|
||||||
|
PARAM_CH_EUC_HITS,
|
||||||
|
PARAM_CH_CV1_DEST,
|
||||||
|
PARAM_CH_CV2_DEST,
|
||||||
|
PARAM_CH_LAST,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to draw centered text
|
||||||
|
void drawCenteredText(const char *text, int y, const uint8_t *font) {
|
||||||
|
gravity.display.setFont(font);
|
||||||
|
int textWidth = gravity.display.getStrWidth(text);
|
||||||
|
gravity.display.drawStr(SCREEN_CENTER_X - (textWidth / 2), y, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to draw right-aligned text
|
||||||
|
void drawRightAlignedText(const char *text, int y) {
|
||||||
|
int textWidth = gravity.display.getStrWidth(text);
|
||||||
|
int drawX = (SCREEN_WIDTH - textWidth) - MENU_BOX_PADDING;
|
||||||
|
gravity.display.drawStr(drawX, y, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawMainSelection() {
|
||||||
|
gravity.display.setDrawColor(1);
|
||||||
|
const int tickSize = 3;
|
||||||
|
const int mainWidth = SCREEN_WIDTH / 2;
|
||||||
|
const int mainHeight = 49;
|
||||||
|
gravity.display.drawLine(0, 0, tickSize, 0);
|
||||||
|
gravity.display.drawLine(0, 0, 0, tickSize);
|
||||||
|
gravity.display.drawLine(mainWidth, 0, mainWidth - tickSize, 0);
|
||||||
|
gravity.display.drawLine(mainWidth, 0, mainWidth, tickSize);
|
||||||
|
gravity.display.drawLine(mainWidth, mainHeight, mainWidth,
|
||||||
|
mainHeight - tickSize);
|
||||||
|
gravity.display.drawLine(mainWidth, mainHeight, mainWidth - tickSize,
|
||||||
|
mainHeight);
|
||||||
|
gravity.display.drawLine(0, mainHeight, tickSize, mainHeight);
|
||||||
|
gravity.display.drawLine(0, mainHeight, 0, mainHeight - tickSize);
|
||||||
|
gravity.display.setDrawColor(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawMenuItems(String menu_items[], int menu_size) {
|
||||||
|
// Draw menu items
|
||||||
|
gravity.display.setFont(TEXT_FONT);
|
||||||
|
|
||||||
|
// Draw selected menu item box
|
||||||
|
int selectedBoxY = 0;
|
||||||
|
if (menu_size >= VISIBLE_MENU_ITEMS && app.selected_param == menu_size - 1) {
|
||||||
|
selectedBoxY = MENU_ITEM_HEIGHT * min(2, app.selected_param);
|
||||||
|
} else if (app.selected_param > 0) {
|
||||||
|
selectedBoxY = MENU_ITEM_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
int boxX = MENU_BOX_WIDTH + 1;
|
||||||
|
int boxY = selectedBoxY + 2;
|
||||||
|
int boxWidth = MENU_BOX_WIDTH - 1;
|
||||||
|
int boxHeight = MENU_ITEM_HEIGHT + 1;
|
||||||
|
|
||||||
|
if (app.editing_param) {
|
||||||
|
gravity.display.drawBox(boxX, boxY, boxWidth, boxHeight);
|
||||||
|
drawMainSelection();
|
||||||
|
} else {
|
||||||
|
gravity.display.drawFrame(boxX, boxY, boxWidth, boxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw the visible menu items
|
||||||
|
int start_index = 0;
|
||||||
|
if (menu_size >= VISIBLE_MENU_ITEMS && app.selected_param == menu_size - 1) {
|
||||||
|
start_index = menu_size - VISIBLE_MENU_ITEMS;
|
||||||
|
} else if (app.selected_param > 0) {
|
||||||
|
start_index = app.selected_param - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < min(menu_size, VISIBLE_MENU_ITEMS); ++i) {
|
||||||
|
int idx = start_index + i;
|
||||||
|
drawRightAlignedText(menu_items[idx].c_str(),
|
||||||
|
MENU_ITEM_HEIGHT * (i + 1) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visual indicators for main section of screen.
|
||||||
|
inline void solidTick() { gravity.display.drawBox(56, 4, 4, 4); }
|
||||||
|
inline void hollowTick() { gravity.display.drawBox(56, 4, 4, 4); }
|
||||||
|
|
||||||
|
// Human friendly display value for save slot.
|
||||||
|
String displaySaveSlot(int slot) {
|
||||||
|
if (slot >= 0 && slot < StateManager::MAX_SAVE_SLOTS / 2) {
|
||||||
|
return String("A") + String(slot + 1);
|
||||||
|
} else if (slot >= StateManager::MAX_SAVE_SLOTS / 2 &&
|
||||||
|
slot <= StateManager::MAX_SAVE_SLOTS) {
|
||||||
|
return String("B") + String(slot - (StateManager::MAX_SAVE_SLOTS / 2) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main display functions
|
||||||
|
|
||||||
|
void DisplayMainPage() {
|
||||||
|
gravity.display.setFontMode(1);
|
||||||
|
gravity.display.setDrawColor(2);
|
||||||
|
gravity.display.setFont(TEXT_FONT);
|
||||||
|
|
||||||
|
// Display selected editable value
|
||||||
|
String mainText;
|
||||||
|
String subText;
|
||||||
|
|
||||||
|
switch (app.selected_param) {
|
||||||
|
case PARAM_MAIN_TEMPO:
|
||||||
|
// Serial MIDI is too unstable to display bpm in real time.
|
||||||
|
if (app.selected_source == Clock::SOURCE_EXTERNAL_MIDI) {
|
||||||
|
mainText = F("EXT");
|
||||||
|
} else {
|
||||||
|
mainText = String(gravity.clock.Tempo());
|
||||||
|
}
|
||||||
|
subText = F("BPM");
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_RUN:
|
||||||
|
mainText = F("RUN");
|
||||||
|
switch (app.cv_run) {
|
||||||
|
case 0:
|
||||||
|
subText = F("NONE");
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
subText = F("CV1 GATE");
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
subText = F("CV2 GATE");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_RESET:
|
||||||
|
mainText = F("RST");
|
||||||
|
switch (app.cv_reset) {
|
||||||
|
case 0:
|
||||||
|
subText = F("NONE");
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
subText = F("CV1 TRIG");
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
subText = F("CV2 TRIG");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_SOURCE:
|
||||||
|
mainText = F("EXT");
|
||||||
|
switch (app.selected_source) {
|
||||||
|
case Clock::SOURCE_INTERNAL:
|
||||||
|
mainText = F("INT");
|
||||||
|
subText = F("CLOCK");
|
||||||
|
break;
|
||||||
|
case Clock::SOURCE_EXTERNAL_PPQN_24:
|
||||||
|
subText = F("24 PPQN");
|
||||||
|
break;
|
||||||
|
case Clock::SOURCE_EXTERNAL_PPQN_4:
|
||||||
|
subText = F("4 PPQN");
|
||||||
|
break;
|
||||||
|
case Clock::SOURCE_EXTERNAL_PPQN_2:
|
||||||
|
subText = F("2 PPQN");
|
||||||
|
break;
|
||||||
|
case Clock::SOURCE_EXTERNAL_PPQN_1:
|
||||||
|
subText = F("1 PPQN");
|
||||||
|
break;
|
||||||
|
case Clock::SOURCE_EXTERNAL_MIDI:
|
||||||
|
subText = F("MIDI");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_PULSE:
|
||||||
|
mainText = F("OUT");
|
||||||
|
switch (app.selected_pulse) {
|
||||||
|
case Clock::PULSE_NONE:
|
||||||
|
subText = F("PULSE OFF");
|
||||||
|
break;
|
||||||
|
case Clock::PULSE_PPQN_24:
|
||||||
|
subText = F("24 PPQN PULSE");
|
||||||
|
break;
|
||||||
|
case Clock::PULSE_PPQN_4:
|
||||||
|
subText = F("4 PPQN PULSE");
|
||||||
|
break;
|
||||||
|
case Clock::PULSE_PPQN_1:
|
||||||
|
subText = F("1 PPQN PULSE");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_ENCODER_DIR:
|
||||||
|
mainText = F("DIR");
|
||||||
|
subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("REVERSED");
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_ROTATE_DISP:
|
||||||
|
mainText = F("DISP");
|
||||||
|
subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("ROTATED");
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_SAVE_DATA:
|
||||||
|
case PARAM_MAIN_LOAD_DATA:
|
||||||
|
if (app.selected_sub_param == StateManager::MAX_SAVE_SLOTS) {
|
||||||
|
mainText = F("x");
|
||||||
|
subText = F("BACK TO MAIN");
|
||||||
|
} else {
|
||||||
|
// Indicate currently active slot.
|
||||||
|
if (app.selected_sub_param == app.selected_save_slot) {
|
||||||
|
solidTick();
|
||||||
|
}
|
||||||
|
mainText = displaySaveSlot(app.selected_sub_param);
|
||||||
|
subText = (app.selected_param == PARAM_MAIN_SAVE_DATA)
|
||||||
|
? F("SAVE TO SLOT")
|
||||||
|
: F("LOAD FROM SLOT");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_RESET_STATE:
|
||||||
|
if (app.selected_sub_param == 0) {
|
||||||
|
mainText = F("RST");
|
||||||
|
subText = F("RESET ALL");
|
||||||
|
} else {
|
||||||
|
mainText = F("x");
|
||||||
|
subText = F("BACK TO MAIN");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_FACTORY_RESET:
|
||||||
|
if (app.selected_sub_param == 0) {
|
||||||
|
mainText = F("DEL");
|
||||||
|
subText = F("FACTORY RESET");
|
||||||
|
} else {
|
||||||
|
mainText = F("x");
|
||||||
|
subText = F("BACK TO MAIN");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCenteredText(mainText.c_str(), MAIN_TEXT_Y, LARGE_FONT);
|
||||||
|
drawCenteredText(subText.c_str(), SUB_TEXT_Y, TEXT_FONT);
|
||||||
|
|
||||||
|
// Draw Main Page menu items
|
||||||
|
String menu_items[PARAM_MAIN_LAST] = {
|
||||||
|
F("TEMPO"), F("RUN"), F("RST"), F("SOURCE"),
|
||||||
|
F("PULSE OUT"), F("ENCODER DIR"), F("ROTATE DISP"), F("SAVE"),
|
||||||
|
F("LOAD"), F("RESET"), F("ERASE")};
|
||||||
|
drawMenuItems(menu_items, PARAM_MAIN_LAST);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayChannelPage() {
|
||||||
|
auto &ch = GetSelectedChannel();
|
||||||
|
|
||||||
|
gravity.display.setFontMode(1);
|
||||||
|
gravity.display.setDrawColor(2);
|
||||||
|
|
||||||
|
// Display selected editable value
|
||||||
|
String mainText;
|
||||||
|
String subText;
|
||||||
|
|
||||||
|
// When editing a param, just show the base value. When not editing show
|
||||||
|
// the value with cv mod.
|
||||||
|
bool withCvMod = !app.editing_param;
|
||||||
|
|
||||||
|
switch (app.selected_param) {
|
||||||
|
case PARAM_CH_MOD: {
|
||||||
|
int mod_value = ch.getClockMod(withCvMod);
|
||||||
|
if (mod_value > 1) {
|
||||||
|
mainText = F("/");
|
||||||
|
mainText += String(mod_value);
|
||||||
|
subText = F("DIVIDE");
|
||||||
|
} else {
|
||||||
|
mainText = F("x");
|
||||||
|
mainText += String(abs(mod_value));
|
||||||
|
subText = F("MULTIPLY");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case PARAM_CH_EUC_STEPS:
|
||||||
|
mainText = String(ch.getSteps(withCvMod));
|
||||||
|
subText = "EUCLID STEPS";
|
||||||
|
break;
|
||||||
|
case PARAM_CH_EUC_HITS:
|
||||||
|
mainText = String(ch.getHits(withCvMod));
|
||||||
|
subText = "EUCLID HITS";
|
||||||
|
break;
|
||||||
|
case PARAM_CH_CV1_DEST:
|
||||||
|
case PARAM_CH_CV2_DEST: {
|
||||||
|
mainText = (app.selected_param == PARAM_CH_CV1_DEST) ? F("CV1") : F("CV2");
|
||||||
|
switch ((app.selected_param == PARAM_CH_CV1_DEST) ? ch.getCv1Dest()
|
||||||
|
: ch.getCv2Dest()) {
|
||||||
|
case CV_DEST_NONE:
|
||||||
|
subText = F("NONE");
|
||||||
|
break;
|
||||||
|
case CV_DEST_MOD:
|
||||||
|
subText = F("CLOCK MOD");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CV_DEST_EUC_STEPS:
|
||||||
|
subText = F("EUCLID STEPS");
|
||||||
|
break;
|
||||||
|
case CV_DEST_EUC_HITS:
|
||||||
|
subText = F("EUCLID HITS");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCenteredText(mainText.c_str(), MAIN_TEXT_Y, LARGE_FONT);
|
||||||
|
drawCenteredText(subText.c_str(), SUB_TEXT_Y, TEXT_FONT);
|
||||||
|
|
||||||
|
// Draw Channel Page menu items
|
||||||
|
String menu_items[PARAM_CH_LAST] = {F("MOD"), F("EUCLID STEPS"),
|
||||||
|
F("EUCLID HITS"), F("CV1 MOD"),
|
||||||
|
F("CV2 MOD")};
|
||||||
|
drawMenuItems(menu_items, PARAM_CH_LAST);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplaySelectedChannel() {
|
||||||
|
int boxX = CHANNEL_BOX_WIDTH;
|
||||||
|
int boxY = CHANNEL_BOXES_Y;
|
||||||
|
int boxWidth = CHANNEL_BOX_WIDTH;
|
||||||
|
int boxHeight = CHANNEL_BOX_HEIGHT;
|
||||||
|
int textOffset = 7; // Half of font width
|
||||||
|
|
||||||
|
// Draw top and right side of frame.
|
||||||
|
gravity.display.drawHLine(1, boxY, SCREEN_WIDTH - 2);
|
||||||
|
gravity.display.drawVLine(SCREEN_WIDTH - 2, boxY, boxHeight);
|
||||||
|
|
||||||
|
for (int i = 0; i < Gravity::OUTPUT_COUNT + 1; i++) {
|
||||||
|
// Draw box frame or filled selected box.
|
||||||
|
gravity.display.setDrawColor(1);
|
||||||
|
(app.selected_channel == i)
|
||||||
|
? gravity.display.drawBox(i * boxWidth, boxY, boxWidth, boxHeight)
|
||||||
|
: gravity.display.drawVLine(i * boxWidth, boxY, boxHeight);
|
||||||
|
|
||||||
|
// Draw clock status icon or each channel number.
|
||||||
|
gravity.display.setDrawColor(2);
|
||||||
|
if (i == 0) {
|
||||||
|
gravity.display.setBitmapMode(1);
|
||||||
|
auto icon = gravity.clock.IsPaused() ? pause_icon : play_icon;
|
||||||
|
gravity.display.drawXBMP(2, boxY, play_icon_width, play_icon_height,
|
||||||
|
icon);
|
||||||
|
} else {
|
||||||
|
gravity.display.setFont(TEXT_FONT);
|
||||||
|
gravity.display.setCursor((i * boxWidth) + textOffset, SCREEN_HEIGHT - 3);
|
||||||
|
gravity.display.print(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void UpdateDisplay() {
|
||||||
|
app.refresh_screen = false;
|
||||||
|
gravity.display.firstPage();
|
||||||
|
do {
|
||||||
|
if (app.selected_channel == 0) {
|
||||||
|
DisplayMainPage();
|
||||||
|
} else {
|
||||||
|
DisplayChannelPage();
|
||||||
|
}
|
||||||
|
// Global channel select UI.
|
||||||
|
DisplaySelectedChannel();
|
||||||
|
} while (gravity.display.nextPage());
|
||||||
|
}
|
||||||
|
|
||||||
|
void Bootsplash() {
|
||||||
|
gravity.display.firstPage();
|
||||||
|
do {
|
||||||
|
int textWidth;
|
||||||
|
String loadingText = F("LOADING....");
|
||||||
|
gravity.display.setFont(TEXT_FONT);
|
||||||
|
|
||||||
|
textWidth = gravity.display.getStrWidth(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,
|
||||||
|
StateManager::SEMANTIC_VERSION);
|
||||||
|
|
||||||
|
textWidth = gravity.display.getStrWidth(loadingText.c_str());
|
||||||
|
gravity.display.drawStr(26 + (textWidth / 2), 44, loadingText.c_str());
|
||||||
|
} while (gravity.display.nextPage());
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // DISPLAY_H
|
||||||
237
firmware/Euclidean/save_state.cpp
Normal file
237
firmware/Euclidean/save_state.cpp
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* @file save_state.cpp
|
||||||
|
* @author Adam Wonak (https://github.com/awonak/)
|
||||||
|
* @brief Alt firmware version of Gravity by Sitka Instruments.
|
||||||
|
* @version 2.0.1
|
||||||
|
* @date 2025-07-04
|
||||||
|
*
|
||||||
|
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "save_state.h"
|
||||||
|
|
||||||
|
#include <EEPROM.h>
|
||||||
|
|
||||||
|
#include "app_state.h"
|
||||||
|
|
||||||
|
// Define the constants for the current firmware.
|
||||||
|
const char StateManager::SKETCH_NAME[] = "ALT EUCLIDEAN";
|
||||||
|
const char StateManager::SEMANTIC_VERSION[] =
|
||||||
|
"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;
|
||||||
|
const byte StateManager::TRANSIENT_SLOT = 10;
|
||||||
|
|
||||||
|
// Define the minimum amount of time between EEPROM writes.
|
||||||
|
const unsigned long StateManager::SAVE_DELAY_MS = 2000;
|
||||||
|
|
||||||
|
// Calculate the starting address for EepromData, leaving space for metadata.
|
||||||
|
const int StateManager::METADATA_START_ADDR = 0;
|
||||||
|
const int StateManager::EEPROM_DATA_START_ADDR = sizeof(StateManager::Metadata);
|
||||||
|
|
||||||
|
StateManager::StateManager() : _isDirty(false), _lastChangeTime(0) {}
|
||||||
|
|
||||||
|
bool StateManager::initialize(AppState &app) {
|
||||||
|
noInterrupts();
|
||||||
|
bool success = false;
|
||||||
|
if (_isDataValid()) {
|
||||||
|
// Load global settings.
|
||||||
|
_loadMetadata(app);
|
||||||
|
// Load app data from the transient slot.
|
||||||
|
_loadState(app, TRANSIENT_SLOT);
|
||||||
|
success = true;
|
||||||
|
}
|
||||||
|
// EEPROM does not contain save data for this firmware & version.
|
||||||
|
else {
|
||||||
|
// Erase EEPROM and initialize state. Save default pattern to all save
|
||||||
|
// slots.
|
||||||
|
factoryReset(app);
|
||||||
|
}
|
||||||
|
interrupts();
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool StateManager::loadData(AppState &app, byte slot_index) {
|
||||||
|
// Check if slot_index is within max range + 1 for transient.
|
||||||
|
if (slot_index >= MAX_SAVE_SLOTS + 1)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
noInterrupts();
|
||||||
|
|
||||||
|
// Load the state data from the specified EEPROM slot and update the app state
|
||||||
|
// save slot.
|
||||||
|
_loadState(app, slot_index);
|
||||||
|
app.selected_save_slot = slot_index;
|
||||||
|
// Persist this change in the global metadata on next update.
|
||||||
|
_isDirty = true;
|
||||||
|
|
||||||
|
interrupts();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save app state to user specified save slot.
|
||||||
|
void StateManager::saveData(const AppState &app) {
|
||||||
|
noInterrupts();
|
||||||
|
// Check if slot_index is within max range + 1 for transient.
|
||||||
|
if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) {
|
||||||
|
interrupts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveState(app, app.selected_save_slot);
|
||||||
|
_saveMetadata(app);
|
||||||
|
_isDirty = false;
|
||||||
|
interrupts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save transient state if it has changed and enough time has passed since last
|
||||||
|
// save.
|
||||||
|
void StateManager::update(const AppState &app) {
|
||||||
|
if (_isDirty && (millis() - _lastChangeTime > SAVE_DELAY_MS)) {
|
||||||
|
noInterrupts();
|
||||||
|
_saveState(app, TRANSIENT_SLOT);
|
||||||
|
_saveMetadata(app);
|
||||||
|
_isDirty = false;
|
||||||
|
interrupts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void StateManager::reset(AppState &app) {
|
||||||
|
noInterrupts();
|
||||||
|
|
||||||
|
AppState default_app;
|
||||||
|
app.tempo = default_app.tempo;
|
||||||
|
app.selected_param = default_app.selected_param;
|
||||||
|
app.selected_channel = default_app.selected_channel;
|
||||||
|
app.selected_source = default_app.selected_source;
|
||||||
|
app.selected_pulse = default_app.selected_pulse;
|
||||||
|
app.cv_run = default_app.cv_run;
|
||||||
|
app.cv_reset = default_app.cv_reset;
|
||||||
|
|
||||||
|
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
||||||
|
app.channel[i].Init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load global settings from Metadata
|
||||||
|
_loadMetadata(app);
|
||||||
|
|
||||||
|
_isDirty = false;
|
||||||
|
interrupts();
|
||||||
|
}
|
||||||
|
|
||||||
|
void StateManager::markDirty() {
|
||||||
|
_isDirty = true;
|
||||||
|
_lastChangeTime = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erases all data in the EEPROM by writing 0 to every address.
|
||||||
|
void StateManager::factoryReset(AppState &app) {
|
||||||
|
noInterrupts();
|
||||||
|
for (unsigned int i = 0; i < EEPROM.length(); i++) {
|
||||||
|
EEPROM.write(i, 0);
|
||||||
|
}
|
||||||
|
// Initialize eeprom and save default patter to all save slots.
|
||||||
|
_saveMetadata(app);
|
||||||
|
reset(app);
|
||||||
|
for (int i = 0; i < MAX_SAVE_SLOTS; i++) {
|
||||||
|
app.selected_save_slot = i;
|
||||||
|
_saveState(app, i);
|
||||||
|
}
|
||||||
|
_saveState(app, TRANSIENT_SLOT);
|
||||||
|
interrupts();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool StateManager::_isDataValid() {
|
||||||
|
Metadata metadata;
|
||||||
|
EEPROM.get(METADATA_START_ADDR, metadata);
|
||||||
|
bool name_match = (strcmp(metadata.sketch_name, SKETCH_NAME) == 0);
|
||||||
|
bool version_match = (strcmp(metadata.version, SEMANTIC_VERSION) == 0);
|
||||||
|
return name_match && version_match;
|
||||||
|
}
|
||||||
|
|
||||||
|
void StateManager::_saveState(const AppState &app, byte slot_index) {
|
||||||
|
// Check if slot_index is within max range + 1 for transient.
|
||||||
|
if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
static EepromData save_data;
|
||||||
|
|
||||||
|
save_data.tempo = app.tempo;
|
||||||
|
save_data.selected_param = app.selected_param;
|
||||||
|
save_data.selected_channel = app.selected_channel;
|
||||||
|
save_data.selected_source = static_cast<byte>(app.selected_source);
|
||||||
|
save_data.selected_pulse = static_cast<byte>(app.selected_pulse);
|
||||||
|
save_data.cv_run = app.cv_run;
|
||||||
|
save_data.cv_reset = app.cv_reset;
|
||||||
|
|
||||||
|
// TODO: break this out into a separate function. Save State should be
|
||||||
|
// broken out into global / per-channel save methods. When saving via
|
||||||
|
// "update" only save state for the current channel since other channels
|
||||||
|
// will not have changed when saving user edits.
|
||||||
|
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
||||||
|
const auto &ch = app.channel[i];
|
||||||
|
auto &save_ch = save_data.channel_data[i];
|
||||||
|
save_ch.base_clock_mod_index = ch.getClockModIndex(false);
|
||||||
|
save_ch.base_euc_steps = ch.getSteps(false);
|
||||||
|
save_ch.base_euc_hits = ch.getHits(false);
|
||||||
|
save_ch.cv1_dest = static_cast<byte>(ch.getCv1Dest());
|
||||||
|
save_ch.cv2_dest = static_cast<byte>(ch.getCv2Dest());
|
||||||
|
}
|
||||||
|
|
||||||
|
int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData));
|
||||||
|
EEPROM.put(address, save_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
void StateManager::_loadState(AppState &app, byte slot_index) {
|
||||||
|
// Check if slot_index is within max range + 1 for transient.
|
||||||
|
if (slot_index >= MAX_SAVE_SLOTS + 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
static EepromData load_data;
|
||||||
|
int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData));
|
||||||
|
EEPROM.get(address, load_data);
|
||||||
|
|
||||||
|
// Restore app state from loaded data.
|
||||||
|
app.tempo = load_data.tempo;
|
||||||
|
app.selected_param = load_data.selected_param;
|
||||||
|
app.selected_channel = load_data.selected_channel;
|
||||||
|
app.selected_source = static_cast<Clock::Source>(load_data.selected_source);
|
||||||
|
app.selected_pulse = static_cast<Clock::Pulse>(load_data.selected_pulse);
|
||||||
|
app.cv_run = load_data.cv_run;
|
||||||
|
app.cv_reset = load_data.cv_reset;
|
||||||
|
|
||||||
|
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
||||||
|
auto &ch = app.channel[i];
|
||||||
|
const auto &saved_ch_state = load_data.channel_data[i];
|
||||||
|
|
||||||
|
ch.setClockMod(saved_ch_state.base_clock_mod_index);
|
||||||
|
ch.setSteps(saved_ch_state.base_euc_steps);
|
||||||
|
ch.setHits(saved_ch_state.base_euc_hits);
|
||||||
|
ch.setCv1Dest(static_cast<CvDestination>(saved_ch_state.cv1_dest));
|
||||||
|
ch.setCv2Dest(static_cast<CvDestination>(saved_ch_state.cv2_dest));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void StateManager::_saveMetadata(const AppState &app) {
|
||||||
|
Metadata current_meta;
|
||||||
|
strcpy(current_meta.sketch_name, SKETCH_NAME);
|
||||||
|
strcpy(current_meta.version, SEMANTIC_VERSION);
|
||||||
|
|
||||||
|
// Global user settings
|
||||||
|
current_meta.selected_save_slot = app.selected_save_slot;
|
||||||
|
current_meta.encoder_reversed = app.encoder_reversed;
|
||||||
|
current_meta.rotate_display = app.rotate_display;
|
||||||
|
|
||||||
|
EEPROM.put(METADATA_START_ADDR, current_meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
void StateManager::_loadMetadata(AppState &app) {
|
||||||
|
Metadata metadata;
|
||||||
|
EEPROM.get(METADATA_START_ADDR, metadata);
|
||||||
|
app.selected_save_slot = metadata.selected_save_slot;
|
||||||
|
app.encoder_reversed = metadata.encoder_reversed;
|
||||||
|
app.rotate_display = metadata.rotate_display;
|
||||||
|
}
|
||||||
96
firmware/Euclidean/save_state.h
Normal file
96
firmware/Euclidean/save_state.h
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* @file save_state.h
|
||||||
|
* @author Adam Wonak (https://github.com/awonak/)
|
||||||
|
* @brief Alt firmware version of Gravity by Sitka Instruments.
|
||||||
|
* @version 2.0.1
|
||||||
|
* @date 2025-07-04
|
||||||
|
*
|
||||||
|
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef SAVE_STATE_H
|
||||||
|
#define SAVE_STATE_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <libGravity.h>
|
||||||
|
|
||||||
|
// Forward-declare AppState to avoid circular dependencies.
|
||||||
|
struct AppState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Manages saving and loading of the application state to and from
|
||||||
|
* EEPROM. The number of user slots is defined by MAX_SAVE_SLOTS, and one
|
||||||
|
* additional slot is reseved for transient state to persist state between power
|
||||||
|
* cycles before state is explicitly saved to a user slot. Metadata is stored in
|
||||||
|
* the beginning of the memory space which stores firmware version information
|
||||||
|
* to validate that the data can be loaded into the current version of AppState.
|
||||||
|
*/
|
||||||
|
class StateManager {
|
||||||
|
public:
|
||||||
|
static const char SKETCH_NAME[];
|
||||||
|
static const char SEMANTIC_VERSION[];
|
||||||
|
static const byte MAX_SAVE_SLOTS;
|
||||||
|
static const byte TRANSIENT_SLOT;
|
||||||
|
|
||||||
|
StateManager();
|
||||||
|
|
||||||
|
// Populate the AppState instance with values from EEPROM if they exist.
|
||||||
|
bool initialize(AppState &app);
|
||||||
|
// Load data from specified slot.
|
||||||
|
bool loadData(AppState &app, byte slot_index);
|
||||||
|
// Save data to specified slot.
|
||||||
|
void saveData(const AppState &app);
|
||||||
|
// Reset AppState instance back to default values.
|
||||||
|
void reset(AppState &app);
|
||||||
|
// Call from main loop, check if state has changed and needs to be saved.
|
||||||
|
void update(const AppState &app);
|
||||||
|
// Indicate that state has changed and we should save.
|
||||||
|
void markDirty();
|
||||||
|
// Erase all data stored in the EEPROM.
|
||||||
|
void factoryReset(AppState &app);
|
||||||
|
|
||||||
|
// This struct holds the data that identifies the firmware version.
|
||||||
|
struct Metadata {
|
||||||
|
char sketch_name[16];
|
||||||
|
char version[16];
|
||||||
|
// Additional global/hardware settings
|
||||||
|
byte selected_save_slot;
|
||||||
|
bool encoder_reversed;
|
||||||
|
bool rotate_display;
|
||||||
|
};
|
||||||
|
struct ChannelState {
|
||||||
|
byte base_clock_mod_index;
|
||||||
|
byte base_euc_steps;
|
||||||
|
byte base_euc_hits;
|
||||||
|
byte cv1_dest; // Cast the CvDestination enum as a byte for storage
|
||||||
|
byte cv2_dest; // Cast the CvDestination enum as a byte for storage
|
||||||
|
};
|
||||||
|
// This struct holds all the parameters we want to save.
|
||||||
|
struct EepromData {
|
||||||
|
int tempo;
|
||||||
|
byte selected_param;
|
||||||
|
byte selected_channel;
|
||||||
|
byte selected_source;
|
||||||
|
byte selected_pulse;
|
||||||
|
byte cv_run;
|
||||||
|
byte cv_reset;
|
||||||
|
ChannelState channel_data[Gravity::OUTPUT_COUNT];
|
||||||
|
};
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool _isDataValid();
|
||||||
|
void _saveMetadata(const AppState &app);
|
||||||
|
void _loadMetadata(AppState &app);
|
||||||
|
void _saveState(const AppState &app, byte slot_index);
|
||||||
|
void _loadState(AppState &app, byte slot_index);
|
||||||
|
|
||||||
|
static const unsigned long SAVE_DELAY_MS;
|
||||||
|
static const int METADATA_START_ADDR;
|
||||||
|
static const int EEPROM_DATA_START_ADDR;
|
||||||
|
|
||||||
|
bool _isDirty;
|
||||||
|
unsigned long _lastChangeTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // SAVE_STATE_H
|
||||||
@ -2,11 +2,10 @@
|
|||||||
* @file Gravity.ino
|
* @file Gravity.ino
|
||||||
* @author Adam Wonak (https://github.com/awonak/)
|
* @author Adam Wonak (https://github.com/awonak/)
|
||||||
* @brief Alt firmware version of Gravity by Sitka Instruments.
|
* @brief Alt firmware version of Gravity by Sitka Instruments.
|
||||||
* @version v2.0.0 - June 2025 awonak - Full rewrite
|
* @version v2.0.1beta1 - February 2026 awonak
|
||||||
* @version v1.0 - August 2023 Oleksiy H - Initial release
|
* @date 2026-02-21
|
||||||
* @date 2025-07-04
|
|
||||||
*
|
*
|
||||||
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
|
* @copyright MIT - (c) 2026 - Adam Wonak - adam.wonak@gmail.com
|
||||||
*
|
*
|
||||||
* This version of Gravity firmware is a full rewrite that leverages the
|
* This version of Gravity firmware is a full rewrite that leverages the
|
||||||
* libGravity hardware abstraction library. The goal of this project was to
|
* libGravity hardware abstraction library. The goal of this project was to
|
||||||
@ -34,17 +33,20 @@
|
|||||||
* Play/pause - start or stop the internal clock.
|
* Play/pause - start or stop the internal clock.
|
||||||
*
|
*
|
||||||
* BTN2:
|
* BTN2:
|
||||||
* Shift - hold and rotate encoder to change current selected output channel.
|
* Shift - hold and rotate encoder to change current selected output
|
||||||
|
* channel.
|
||||||
*
|
*
|
||||||
* EXT:
|
* EXT:
|
||||||
* External clock input. When Gravity is set to INTERNAL or MIDI clock
|
* External clock input. When Gravity is set to INTERNAL or MIDI clock
|
||||||
* source, this input is used to reset clocks.
|
* source, this input is used to reset clocks.
|
||||||
*
|
*
|
||||||
* CV1:
|
* CV1:
|
||||||
* External analog input used to provide modulation to any channel parameter.
|
* External analog input used to provide modulation to any channel
|
||||||
|
* parameter.
|
||||||
*
|
*
|
||||||
* CV2:
|
* CV2:
|
||||||
* External analog input used to provide modulation to any channel parameter.
|
* External analog input used to provide modulation to any channel
|
||||||
|
* parameter.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -63,43 +65,75 @@ StateManager stateManager;
|
|||||||
//
|
//
|
||||||
|
|
||||||
void setup() {
|
void setup() {
|
||||||
// Start Gravity.
|
// Start Gravity.
|
||||||
gravity.Init();
|
gravity.Init();
|
||||||
|
|
||||||
// Show bootsplash when initializing firmware.
|
// Show bootsplash when initializing firmware.
|
||||||
Bootsplash();
|
Bootsplash();
|
||||||
delay(2000);
|
delay(2000);
|
||||||
|
|
||||||
// Initialize the state manager. This will load settings from EEPROM
|
// Initialize the state manager. This will load settings from EEPROM
|
||||||
stateManager.initialize(app);
|
stateManager.initialize(app);
|
||||||
InitGravity(app);
|
InitGravity(app);
|
||||||
|
|
||||||
// Clock handlers.
|
// Clock handlers.
|
||||||
gravity.clock.AttachIntHandler(HandleIntClockTick);
|
gravity.clock.AttachIntHandler(HandleIntClockTick);
|
||||||
gravity.clock.AttachExtHandler(HandleExtClockTick);
|
gravity.clock.AttachExtHandler(HandleExtClockTick);
|
||||||
|
|
||||||
// Encoder rotate and press handlers.
|
// Encoder rotate and press handlers.
|
||||||
gravity.encoder.AttachPressHandler(HandleEncoderPressed);
|
gravity.encoder.AttachPressHandler(HandleEncoderPressed);
|
||||||
gravity.encoder.AttachRotateHandler(HandleRotate);
|
gravity.encoder.AttachRotateHandler(HandleRotate);
|
||||||
gravity.encoder.AttachPressRotateHandler(HandlePressedRotate);
|
gravity.encoder.AttachPressRotateHandler(HandlePressedRotate);
|
||||||
|
|
||||||
// Button press handlers.
|
// Button press handlers.
|
||||||
gravity.play_button.AttachPressHandler(HandlePlayPressed);
|
gravity.play_button.AttachPressHandler(HandlePlayPressed);
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
// Process change in state of inputs and outputs.
|
// Process change in state of inputs and outputs.
|
||||||
gravity.Process();
|
gravity.Process();
|
||||||
|
|
||||||
// Check if cv run or reset is active and read cv.
|
// Read CVs and call the update function for each channel.
|
||||||
CheckRunReset(gravity.cv1, gravity.cv2);
|
int cv1 = gravity.cv1.Read();
|
||||||
|
int cv2 = gravity.cv2.Read();
|
||||||
|
|
||||||
// Check for dirty state eligible to be saved.
|
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
||||||
stateManager.update(app);
|
auto &ch = app.channel[i];
|
||||||
|
// Only apply CV to the channel when the current channel has cv
|
||||||
if (app.refresh_screen) {
|
// mod configured.
|
||||||
UpdateDisplay();
|
if (ch.isCvModActive()) {
|
||||||
|
ch.applyCvMod(cv1, cv2);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clock Run
|
||||||
|
if (app.cv_run == 1 || app.cv_run == 2) {
|
||||||
|
auto &cv = app.cv_run == 1 ? gravity.cv1 : gravity.cv2;
|
||||||
|
int val = cv.Read();
|
||||||
|
if (val > AnalogInput::GATE_THRESHOLD && gravity.clock.IsPaused()) {
|
||||||
|
gravity.clock.Start();
|
||||||
|
app.refresh_screen = true;
|
||||||
|
} else if (val < AnalogInput::GATE_THRESHOLD && !gravity.clock.IsPaused()) {
|
||||||
|
gravity.clock.Stop();
|
||||||
|
ResetOutputs();
|
||||||
|
app.refresh_screen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clock Reset
|
||||||
|
if ((app.cv_reset == 1 &&
|
||||||
|
gravity.cv1.IsRisingEdge(AnalogInput::GATE_THRESHOLD)) ||
|
||||||
|
(app.cv_reset == 2 &&
|
||||||
|
gravity.cv2.IsRisingEdge(AnalogInput::GATE_THRESHOLD))) {
|
||||||
|
gravity.clock.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dirty state eligible to be saved.
|
||||||
|
stateManager.update(app);
|
||||||
|
|
||||||
|
if (app.refresh_screen) {
|
||||||
|
UpdateDisplay();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
@ -107,79 +141,59 @@ void loop() {
|
|||||||
//
|
//
|
||||||
|
|
||||||
void HandleIntClockTick(uint32_t tick) {
|
void HandleIntClockTick(uint32_t tick) {
|
||||||
bool refresh = false;
|
bool refresh = false;
|
||||||
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
||||||
app.channel[i].processClockTick(tick, gravity.outputs[i]);
|
app.channel[i].processClockTick(tick, gravity.outputs[i]);
|
||||||
|
|
||||||
if (app.channel[i].isCvModActive()) {
|
if (app.channel[i].isCvModActive()) {
|
||||||
refresh = true;
|
refresh = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulse Out gate
|
||||||
|
if (app.selected_pulse != Clock::PULSE_NONE) {
|
||||||
|
int clock_index;
|
||||||
|
switch (app.selected_pulse) {
|
||||||
|
case Clock::PULSE_PPQN_24:
|
||||||
|
clock_index = PULSE_PPQN_24_CLOCK_MOD_INDEX;
|
||||||
|
break;
|
||||||
|
case Clock::PULSE_PPQN_4:
|
||||||
|
clock_index = PULSE_PPQN_4_CLOCK_MOD_INDEX;
|
||||||
|
break;
|
||||||
|
case Clock::PULSE_PPQN_1:
|
||||||
|
clock_index = PULSE_PPQN_1_CLOCK_MOD_INDEX;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pulse Out gate
|
const uint16_t pulse_high_ticks =
|
||||||
if (app.selected_pulse != Clock::PULSE_NONE) {
|
pgm_read_word_near(&CLOCK_MOD_PULSES[clock_index]);
|
||||||
int clock_index;
|
const uint32_t pulse_low_ticks = tick + max((pulse_high_ticks / 2), 1L);
|
||||||
switch (app.selected_pulse) {
|
|
||||||
case Clock::PULSE_PPQN_24:
|
|
||||||
clock_index = PULSE_PPQN_24_CLOCK_MOD_INDEX;
|
|
||||||
break;
|
|
||||||
case Clock::PULSE_PPQN_4:
|
|
||||||
clock_index = PULSE_PPQN_4_CLOCK_MOD_INDEX;
|
|
||||||
break;
|
|
||||||
case Clock::PULSE_PPQN_1:
|
|
||||||
clock_index = PULSE_PPQN_1_CLOCK_MOD_INDEX;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uint16_t pulse_high_ticks = pgm_read_word_near(&CLOCK_MOD_PULSES[clock_index]);
|
if (tick % pulse_high_ticks == 0) {
|
||||||
const uint32_t pulse_low_ticks = tick + max((pulse_high_ticks / 2), 1L);
|
gravity.pulse.High();
|
||||||
|
} else if (pulse_low_ticks % pulse_high_ticks == 0) {
|
||||||
if (tick % pulse_high_ticks == 0) {
|
gravity.pulse.Low();
|
||||||
gravity.pulse.High();
|
|
||||||
} else if (pulse_low_ticks % pulse_high_ticks == 0) {
|
|
||||||
gravity.pulse.Low();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!app.editing_param) {
|
if (!app.editing_param) {
|
||||||
app.refresh_screen |= refresh;
|
app.refresh_screen |= refresh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void HandleExtClockTick() {
|
void HandleExtClockTick() {
|
||||||
switch (app.selected_source) {
|
switch (app.selected_source) {
|
||||||
case Clock::SOURCE_INTERNAL:
|
case Clock::SOURCE_INTERNAL:
|
||||||
case Clock::SOURCE_EXTERNAL_MIDI:
|
case Clock::SOURCE_EXTERNAL_MIDI:
|
||||||
// Use EXT as Reset when not used for clock source.
|
// Use EXT as Reset when not used for clock source.
|
||||||
ResetOutputs();
|
ResetOutputs();
|
||||||
gravity.clock.Reset();
|
gravity.clock.Reset();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Register EXT cv clock tick.
|
// Register EXT cv clock tick.
|
||||||
gravity.clock.Tick();
|
gravity.clock.Tick();
|
||||||
}
|
}
|
||||||
app.refresh_screen = true;
|
app.refresh_screen = true;
|
||||||
}
|
|
||||||
|
|
||||||
void CheckRunReset(AnalogInput& cv1, AnalogInput& cv2) {
|
|
||||||
// Clock Run
|
|
||||||
if (app.cv_run == 1 || app.cv_run == 2) {
|
|
||||||
const int val = (app.cv_run == 1) ? cv1.Read() : cv2.Read();
|
|
||||||
if (val > AnalogInput::GATE_THRESHOLD && gravity.clock.IsPaused()) {
|
|
||||||
gravity.clock.Start();
|
|
||||||
app.refresh_screen = true;
|
|
||||||
} else if (val < AnalogInput::GATE_THRESHOLD && !gravity.clock.IsPaused()) {
|
|
||||||
gravity.clock.Stop();
|
|
||||||
ResetOutputs();
|
|
||||||
app.refresh_screen = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clock Reset
|
|
||||||
if ((app.cv_reset == 1 && cv1.IsRisingEdge(AnalogInput::GATE_THRESHOLD)) ||
|
|
||||||
(app.cv_reset == 2 && cv2.IsRisingEdge(AnalogInput::GATE_THRESHOLD))) {
|
|
||||||
gravity.clock.Reset();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
@ -187,204 +201,223 @@ void CheckRunReset(AnalogInput& cv1, AnalogInput& cv2) {
|
|||||||
//
|
//
|
||||||
|
|
||||||
void HandlePlayPressed() {
|
void HandlePlayPressed() {
|
||||||
// Check if SHIFT is pressed to mute all/current channel.
|
// Check if SHIFT is pressed to mute all/current channel.
|
||||||
if (gravity.shift_button.On()) {
|
if (gravity.shift_button.On()) {
|
||||||
if (app.selected_channel == 0) {
|
if (app.selected_channel == 0) {
|
||||||
// Mute all channels
|
// Mute all channels
|
||||||
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
||||||
app.channel[i].toggleMute();
|
app.channel[i].toggleMute();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Mute selected channel
|
// Mute selected channel
|
||||||
auto& ch = GetSelectedChannel();
|
auto &ch = GetSelectedChannel();
|
||||||
ch.toggleMute();
|
ch.toggleMute();
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
gravity.clock.IsPaused()
|
gravity.clock.IsPaused() ? gravity.clock.Start() : gravity.clock.Stop();
|
||||||
? gravity.clock.Start()
|
ResetOutputs();
|
||||||
: gravity.clock.Stop();
|
app.refresh_screen = true;
|
||||||
ResetOutputs();
|
|
||||||
app.refresh_screen = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void HandleEncoderPressed() {
|
void HandleEncoderPressed() {
|
||||||
// Check if leaving editing mode should apply a selection.
|
// Check if leaving editing mode should apply a selection.
|
||||||
if (app.editing_param) {
|
if (app.editing_param) {
|
||||||
if (app.selected_channel == 0) { // main page
|
if (app.selected_channel == 0) { // main page
|
||||||
// TODO: rewrite as switch
|
// TODO: rewrite as switch
|
||||||
if (app.selected_param == PARAM_MAIN_ENCODER_DIR) {
|
if (app.selected_param == PARAM_MAIN_ENCODER_DIR) {
|
||||||
app.encoder_reversed = app.selected_sub_param == 1;
|
app.encoder_reversed = app.selected_sub_param == 1;
|
||||||
gravity.encoder.SetReverseDirection(app.encoder_reversed);
|
gravity.encoder.SetReverseDirection(app.encoder_reversed);
|
||||||
}
|
}
|
||||||
if (app.selected_param == PARAM_MAIN_SAVE_DATA) {
|
if (app.selected_param == PARAM_MAIN_ROTATE_DISP) {
|
||||||
if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) {
|
app.rotate_display = app.selected_sub_param == 1;
|
||||||
app.selected_save_slot = app.selected_sub_param;
|
gravity.display.setFlipMode(app.rotate_display ? 1 : 0);
|
||||||
stateManager.saveData(app);
|
}
|
||||||
}
|
if (app.selected_param == PARAM_MAIN_SAVE_DATA) {
|
||||||
}
|
if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) {
|
||||||
if (app.selected_param == PARAM_MAIN_LOAD_DATA) {
|
app.selected_save_slot = app.selected_sub_param;
|
||||||
if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) {
|
stateManager.saveData(app);
|
||||||
app.selected_save_slot = app.selected_sub_param;
|
|
||||||
stateManager.loadData(app, app.selected_save_slot);
|
|
||||||
InitGravity(app);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (app.selected_param == PARAM_MAIN_FACTORY_RESET) {
|
|
||||||
if (app.selected_sub_param == 0) { // Erase
|
|
||||||
// Show bootsplash during slow erase operation.
|
|
||||||
Bootsplash();
|
|
||||||
stateManager.factoryReset(app);
|
|
||||||
InitGravity(app);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Only mark dirty and reset selected_sub_param when leaving editing mode.
|
}
|
||||||
stateManager.markDirty();
|
if (app.selected_param == PARAM_MAIN_LOAD_DATA) {
|
||||||
app.selected_sub_param = 0;
|
if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) {
|
||||||
|
app.selected_save_slot = app.selected_sub_param;
|
||||||
|
// Load pattern data into app state.
|
||||||
|
stateManager.loadData(app, app.selected_save_slot);
|
||||||
|
// Load global performance settings if they have changed.
|
||||||
|
if (gravity.clock.Tempo() != app.tempo) {
|
||||||
|
gravity.clock.SetTempo(app.tempo);
|
||||||
|
}
|
||||||
|
// Load global settings only if clock is not active.
|
||||||
|
if (gravity.clock.IsPaused()) {
|
||||||
|
InitGravity(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (app.selected_param == PARAM_MAIN_RESET_STATE) {
|
||||||
|
if (app.selected_sub_param == 0) { // Reset
|
||||||
|
stateManager.reset(app);
|
||||||
|
InitGravity(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (app.selected_param == PARAM_MAIN_FACTORY_RESET) {
|
||||||
|
if (app.selected_sub_param == 0) { // Erase
|
||||||
|
// Show bootsplash during slow erase operation.
|
||||||
|
Bootsplash();
|
||||||
|
stateManager.factoryReset(app);
|
||||||
|
InitGravity(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Only mark dirty and reset selected_sub_param when leaving editing mode.
|
||||||
|
stateManager.markDirty();
|
||||||
|
app.selected_sub_param = 0;
|
||||||
|
}
|
||||||
|
|
||||||
app.editing_param = !app.editing_param;
|
app.editing_param = !app.editing_param;
|
||||||
app.refresh_screen = true;
|
app.refresh_screen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void HandleRotate(int val) {
|
void HandleRotate(int val) {
|
||||||
// Shift & Rotate check
|
// Shift & Rotate check
|
||||||
if (gravity.shift_button.On()) {
|
if (gravity.shift_button.On()) {
|
||||||
HandlePressedRotate(val);
|
HandlePressedRotate(val);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!app.editing_param) {
|
if (!app.editing_param) {
|
||||||
// Navigation Mode
|
// Navigation Mode
|
||||||
const int max_param = (app.selected_channel == 0) ? PARAM_MAIN_LAST : PARAM_CH_LAST;
|
const int max_param =
|
||||||
updateSelection(app.selected_param, val, max_param);
|
(app.selected_channel == 0) ? PARAM_MAIN_LAST : PARAM_CH_LAST;
|
||||||
|
updateSelection(app.selected_param, val, max_param);
|
||||||
|
} else {
|
||||||
|
// Editing Mode
|
||||||
|
if (app.selected_channel == 0) {
|
||||||
|
editMainParameter(val);
|
||||||
} else {
|
} else {
|
||||||
// Editing Mode
|
editChannelParameter(val);
|
||||||
if (app.selected_channel == 0) {
|
|
||||||
editMainParameter(val);
|
|
||||||
} else {
|
|
||||||
editChannelParameter(val);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
app.refresh_screen = true;
|
}
|
||||||
|
app.refresh_screen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void HandlePressedRotate(int val) {
|
void HandlePressedRotate(int val) {
|
||||||
updateSelection(app.selected_channel, val, Gravity::OUTPUT_COUNT + 1);
|
updateSelection(app.selected_channel, val, Gravity::OUTPUT_COUNT + 1);
|
||||||
app.selected_param = 0;
|
app.selected_param = 0;
|
||||||
stateManager.markDirty();
|
stateManager.markDirty();
|
||||||
app.refresh_screen = true;
|
app.refresh_screen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void editMainParameter(int val) {
|
void editMainParameter(int val) {
|
||||||
switch (static_cast<ParamsMainPage>(app.selected_param)) {
|
switch (static_cast<ParamsMainPage>(app.selected_param)) {
|
||||||
case PARAM_MAIN_TEMPO:
|
case PARAM_MAIN_TEMPO:
|
||||||
if (gravity.clock.ExternalSource()) {
|
if (gravity.clock.ExternalSource()) {
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
gravity.clock.SetTempo(gravity.clock.Tempo() + val);
|
|
||||||
app.tempo = gravity.clock.Tempo();
|
|
||||||
break;
|
|
||||||
case PARAM_MAIN_RUN:
|
|
||||||
updateSelection(app.selected_sub_param, val, 3);
|
|
||||||
app.cv_run = app.selected_sub_param;
|
|
||||||
break;
|
|
||||||
case PARAM_MAIN_RESET:
|
|
||||||
updateSelection(app.selected_sub_param, val, 3);
|
|
||||||
app.cv_reset = app.selected_sub_param;
|
|
||||||
break;
|
|
||||||
case PARAM_MAIN_SOURCE: {
|
|
||||||
byte source = static_cast<int>(app.selected_source);
|
|
||||||
updateSelection(source, val, Clock::SOURCE_LAST);
|
|
||||||
app.selected_source = static_cast<Clock::Source>(source);
|
|
||||||
gravity.clock.SetSource(app.selected_source);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case PARAM_MAIN_PULSE: {
|
|
||||||
byte pulse = static_cast<int>(app.selected_pulse);
|
|
||||||
updateSelection(pulse, val, Clock::PULSE_LAST);
|
|
||||||
app.selected_pulse = static_cast<Clock::Pulse>(pulse);
|
|
||||||
if (app.selected_pulse == Clock::PULSE_NONE) {
|
|
||||||
gravity.pulse.Low();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// These changes are applied upon encoder button press.
|
|
||||||
case PARAM_MAIN_ENCODER_DIR:
|
|
||||||
updateSelection(app.selected_sub_param, val, 2);
|
|
||||||
break;
|
|
||||||
case PARAM_MAIN_SAVE_DATA:
|
|
||||||
case PARAM_MAIN_LOAD_DATA:
|
|
||||||
updateSelection(app.selected_sub_param, val, StateManager::MAX_SAVE_SLOTS + 1);
|
|
||||||
break;
|
|
||||||
case PARAM_MAIN_FACTORY_RESET:
|
|
||||||
updateSelection(app.selected_sub_param, val, 2);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
gravity.clock.SetTempo(gravity.clock.Tempo() + val);
|
||||||
|
app.tempo = gravity.clock.Tempo();
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_RUN:
|
||||||
|
updateSelection(app.selected_sub_param, val, 3);
|
||||||
|
app.cv_run = app.selected_sub_param;
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_RESET:
|
||||||
|
updateSelection(app.selected_sub_param, val, 3);
|
||||||
|
app.cv_reset = app.selected_sub_param;
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_SOURCE: {
|
||||||
|
byte source = static_cast<int>(app.selected_source);
|
||||||
|
updateSelection(source, val, Clock::SOURCE_LAST);
|
||||||
|
app.selected_source = static_cast<Clock::Source>(source);
|
||||||
|
gravity.clock.SetSource(app.selected_source);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PARAM_MAIN_PULSE: {
|
||||||
|
byte pulse = static_cast<int>(app.selected_pulse);
|
||||||
|
updateSelection(pulse, val, Clock::PULSE_LAST);
|
||||||
|
app.selected_pulse = static_cast<Clock::Pulse>(pulse);
|
||||||
|
if (app.selected_pulse == Clock::PULSE_NONE) {
|
||||||
|
gravity.pulse.Low();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// These changes are applied upon encoder button press.
|
||||||
|
case PARAM_MAIN_ENCODER_DIR:
|
||||||
|
updateSelection(app.selected_sub_param, val, 2);
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_ROTATE_DISP:
|
||||||
|
updateSelection(app.selected_sub_param, val, 2);
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_SAVE_DATA:
|
||||||
|
case PARAM_MAIN_LOAD_DATA:
|
||||||
|
updateSelection(app.selected_sub_param, val,
|
||||||
|
StateManager::MAX_SAVE_SLOTS + 1);
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_RESET_STATE:
|
||||||
|
updateSelection(app.selected_sub_param, val, 2);
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_FACTORY_RESET:
|
||||||
|
updateSelection(app.selected_sub_param, val, 2);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void editChannelParameter(int val) {
|
void editChannelParameter(int val) {
|
||||||
auto& ch = GetSelectedChannel();
|
auto &ch = GetSelectedChannel();
|
||||||
switch (app.selected_param) {
|
switch (app.selected_param) {
|
||||||
case PARAM_CH_MOD:
|
case PARAM_CH_MOD:
|
||||||
ch.setClockMod(ch.getClockModIndex() + val);
|
ch.setClockMod(ch.getClockModIndex() + val);
|
||||||
break;
|
break;
|
||||||
case PARAM_CH_PROB:
|
case PARAM_CH_PROB:
|
||||||
ch.setProbability(ch.getProbability() + val);
|
ch.setProbability(ch.getProbability() + val);
|
||||||
break;
|
break;
|
||||||
case PARAM_CH_DUTY:
|
case PARAM_CH_DUTY:
|
||||||
ch.setDutyCycle(ch.getDutyCycle() + val);
|
ch.setDutyCycle(ch.getDutyCycle() + val);
|
||||||
break;
|
break;
|
||||||
case PARAM_CH_OFFSET:
|
case PARAM_CH_OFFSET:
|
||||||
ch.setOffset(ch.getOffset() + val);
|
ch.setOffset(ch.getOffset() + val);
|
||||||
break;
|
break;
|
||||||
case PARAM_CH_SWING:
|
case PARAM_CH_SWING:
|
||||||
ch.setSwing(ch.getSwing() + val);
|
ch.setSwing(ch.getSwing() + val);
|
||||||
break;
|
break;
|
||||||
case PARAM_CH_EUC_STEPS:
|
case PARAM_CH_CV1_DEST: {
|
||||||
ch.setSteps(ch.getSteps() + val);
|
byte dest = static_cast<int>(ch.getCv1Dest());
|
||||||
break;
|
updateSelection(dest, val, CV_DEST_LAST);
|
||||||
case PARAM_CH_EUC_HITS:
|
ch.setCv1Dest(static_cast<CvDestination>(dest));
|
||||||
ch.setHits(ch.getHits() + val);
|
break;
|
||||||
break;
|
}
|
||||||
case PARAM_CH_CV1_DEST: {
|
case PARAM_CH_CV2_DEST: {
|
||||||
byte dest = static_cast<int>(ch.getCv1Dest());
|
byte dest = static_cast<int>(ch.getCv2Dest());
|
||||||
updateSelection(dest, val, CV_DEST_LAST);
|
updateSelection(dest, val, CV_DEST_LAST);
|
||||||
ch.setCv1Dest(static_cast<CvDestination>(dest));
|
ch.setCv2Dest(static_cast<CvDestination>(dest));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case PARAM_CH_CV2_DEST: {
|
}
|
||||||
byte dest = static_cast<int>(ch.getCv2Dest());
|
|
||||||
updateSelection(dest, val, CV_DEST_LAST);
|
|
||||||
ch.setCv2Dest(static_cast<CvDestination>(dest));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Changes the param by the value provided.
|
// Changes the param by the value provided.
|
||||||
void updateSelection(byte& param, int change, int maxValue) {
|
void updateSelection(byte ¶m, int change, int maxValue) {
|
||||||
// Do not apply acceleration if max value is less than 25.
|
// Do not apply acceleration if max value is less than 25.
|
||||||
if (maxValue < 25) {
|
if (maxValue < 25) {
|
||||||
change = change > 0 ? 1 : -1;
|
change = change > 0 ? 1 : -1;
|
||||||
}
|
}
|
||||||
param = constrain(param + change, 0, maxValue - 1);
|
param = constrain(param + change, 0, maxValue - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// App Helper functions.
|
// App Helper functions.
|
||||||
//
|
//
|
||||||
|
|
||||||
void InitGravity(AppState& app) {
|
void InitGravity(AppState &app) {
|
||||||
gravity.clock.SetTempo(app.tempo);
|
gravity.clock.SetTempo(app.tempo);
|
||||||
gravity.clock.SetSource(app.selected_source);
|
gravity.clock.SetSource(app.selected_source);
|
||||||
gravity.encoder.SetReverseDirection(app.encoder_reversed);
|
gravity.encoder.SetReverseDirection(app.encoder_reversed);
|
||||||
|
gravity.display.setFlipMode(app.rotate_display ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ResetOutputs() {
|
void ResetOutputs() {
|
||||||
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
||||||
gravity.outputs[i].Low();
|
gravity.outputs[i].Low();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,26 +18,27 @@
|
|||||||
|
|
||||||
// Global state for settings and app behavior.
|
// Global state for settings and app behavior.
|
||||||
struct AppState {
|
struct AppState {
|
||||||
int tempo = Clock::DEFAULT_TEMPO;
|
int tempo = Clock::DEFAULT_TEMPO;
|
||||||
Channel channel[Gravity::OUTPUT_COUNT];
|
Channel channel[Gravity::OUTPUT_COUNT];
|
||||||
byte selected_param = 0;
|
byte selected_param = 0;
|
||||||
byte selected_sub_param = 0; // Temporary value for editing params.
|
byte selected_sub_param = 0; // Temporary value for editing params.
|
||||||
byte selected_channel = 0; // 0=tempo, 1-6=output channel
|
byte selected_channel = 0; // 0=tempo, 1-6=output channel
|
||||||
byte selected_swing = 0;
|
byte selected_swing = 0;
|
||||||
byte selected_save_slot = 0; // The currently active save slot.
|
byte selected_save_slot = 0; // The currently active save slot.
|
||||||
byte cv_run = 0;
|
byte cv_run = 0;
|
||||||
byte cv_reset = 0;
|
byte cv_reset = 0;
|
||||||
Clock::Source selected_source = Clock::SOURCE_INTERNAL;
|
Clock::Source selected_source = Clock::SOURCE_INTERNAL;
|
||||||
Clock::Pulse selected_pulse = Clock::PULSE_PPQN_24;
|
Clock::Pulse selected_pulse = Clock::PULSE_PPQN_24;
|
||||||
bool editing_param = false;
|
bool editing_param = false;
|
||||||
bool encoder_reversed = false;
|
bool encoder_reversed = false;
|
||||||
bool refresh_screen = true;
|
bool rotate_display = false;
|
||||||
|
bool refresh_screen = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
extern AppState app;
|
extern AppState app;
|
||||||
|
|
||||||
static Channel& GetSelectedChannel() {
|
static Channel &GetSelectedChannel() {
|
||||||
return app.channel[app.selected_channel - 1];
|
return app.channel[app.selected_channel - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif // APP_STATE_H
|
#endif // APP_STATE_H
|
||||||
@ -15,19 +15,15 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <libGravity.h>
|
#include <libGravity.h>
|
||||||
|
|
||||||
#include "euclidean.h"
|
|
||||||
|
|
||||||
// Enums for CV Mod destination
|
// Enums for CV Mod destination
|
||||||
enum CvDestination : uint8_t {
|
enum CvDestination : uint8_t {
|
||||||
CV_DEST_NONE,
|
CV_DEST_NONE,
|
||||||
CV_DEST_MOD,
|
CV_DEST_MOD,
|
||||||
CV_DEST_PROB,
|
CV_DEST_PROB,
|
||||||
CV_DEST_DUTY,
|
CV_DEST_DUTY,
|
||||||
CV_DEST_OFFSET,
|
CV_DEST_OFFSET,
|
||||||
CV_DEST_SWING,
|
CV_DEST_SWING,
|
||||||
CV_DEST_EUC_STEPS,
|
CV_DEST_LAST,
|
||||||
CV_DEST_EUC_HITS,
|
|
||||||
CV_DEST_LAST,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static const byte MOD_CHOICE_SIZE = 25;
|
static const byte MOD_CHOICE_SIZE = 25;
|
||||||
@ -45,242 +41,256 @@ static const int CLOCK_MOD[MOD_CHOICE_SIZE] PROGMEM = {
|
|||||||
// that match the above div/mult mods.
|
// that match the above div/mult mods.
|
||||||
static const int CLOCK_MOD_PULSES[MOD_CHOICE_SIZE] PROGMEM = {
|
static const int CLOCK_MOD_PULSES[MOD_CHOICE_SIZE] PROGMEM = {
|
||||||
// Divisor Pulses (96 * X)
|
// Divisor Pulses (96 * X)
|
||||||
12288, 6144, 3072, 2304, 1536, 1152, 1056, 960, 864, 768, 672, 576, 480, 384, 288, 192,
|
12288, 6144, 3072, 2304, 1536, 1152, 1056, 960, 864, 768, 672, 576, 480,
|
||||||
|
384, 288, 192,
|
||||||
// Internal Clock Pulses
|
// Internal Clock Pulses
|
||||||
96,
|
96,
|
||||||
// Multiplier Pulses (96 / X)
|
// Multiplier Pulses (96 / X)
|
||||||
48, 32, 24, 16, 12, 8, 6, 4};
|
48, 32, 24, 16, 12, 8, 6, 4};
|
||||||
|
|
||||||
static const byte DEFAULT_CLOCK_MOD_INDEX = 16; // x1 or 96 PPQN.
|
static const byte DEFAULT_CLOCK_MOD_INDEX = 16; // x1 or 96 PPQN.
|
||||||
|
|
||||||
static const byte PULSE_PPQN_24_CLOCK_MOD_INDEX = MOD_CHOICE_SIZE - 1;
|
static const byte PULSE_PPQN_24_CLOCK_MOD_INDEX = MOD_CHOICE_SIZE - 1;
|
||||||
static const byte PULSE_PPQN_4_CLOCK_MOD_INDEX = MOD_CHOICE_SIZE - 6;
|
static const byte PULSE_PPQN_4_CLOCK_MOD_INDEX = MOD_CHOICE_SIZE - 6;
|
||||||
static const byte PULSE_PPQN_1_CLOCK_MOD_INDEX = MOD_CHOICE_SIZE - 9;
|
static const byte PULSE_PPQN_1_CLOCK_MOD_INDEX = MOD_CHOICE_SIZE - 9;
|
||||||
|
|
||||||
class Channel {
|
class Channel {
|
||||||
public:
|
public:
|
||||||
Channel() {
|
Channel() { Init(); }
|
||||||
Init();
|
|
||||||
|
void Init() {
|
||||||
|
// Reset base values to their defaults
|
||||||
|
base_clock_mod_index = DEFAULT_CLOCK_MOD_INDEX;
|
||||||
|
base_probability = 100;
|
||||||
|
base_duty_cycle = 50;
|
||||||
|
base_offset = 0;
|
||||||
|
base_swing = 50;
|
||||||
|
|
||||||
|
cvmod_clock_mod_index = base_clock_mod_index;
|
||||||
|
cvmod_probability = base_probability;
|
||||||
|
cvmod_duty_cycle = base_duty_cycle;
|
||||||
|
cvmod_offset = base_offset;
|
||||||
|
cvmod_swing = base_swing;
|
||||||
|
|
||||||
|
cv1_dest = CV_DEST_NONE;
|
||||||
|
cv2_dest = CV_DEST_NONE;
|
||||||
|
|
||||||
|
// Calcule the clock mod pulses on init.
|
||||||
|
_recalculatePulses();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setters (Set the BASE value)
|
||||||
|
|
||||||
|
void setClockMod(int index) {
|
||||||
|
base_clock_mod_index = constrain(index, 0, MOD_CHOICE_SIZE - 1);
|
||||||
|
if (!isCvModActive()) {
|
||||||
|
cvmod_clock_mod_index = base_clock_mod_index;
|
||||||
|
_recalculatePulses();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setProbability(int prob) {
|
||||||
|
base_probability = constrain(prob, 0, 100);
|
||||||
|
if (!isCvModActive()) {
|
||||||
|
cvmod_probability = base_probability;
|
||||||
|
_recalculatePulses();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setDutyCycle(int duty) {
|
||||||
|
base_duty_cycle = constrain(duty, 1, 99);
|
||||||
|
if (!isCvModActive()) {
|
||||||
|
cvmod_duty_cycle = base_duty_cycle;
|
||||||
|
_recalculatePulses();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setOffset(int off) {
|
||||||
|
base_offset = constrain(off, 0, 99);
|
||||||
|
if (!isCvModActive()) {
|
||||||
|
cvmod_offset = base_offset;
|
||||||
|
_recalculatePulses();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void setSwing(int val) {
|
||||||
|
base_swing = constrain(val, 50, 95);
|
||||||
|
if (!isCvModActive()) {
|
||||||
|
cvmod_swing = base_swing;
|
||||||
|
_recalculatePulses();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCv1Dest(CvDestination dest) { cv1_dest = dest; }
|
||||||
|
void setCv2Dest(CvDestination dest) { cv2_dest = dest; }
|
||||||
|
CvDestination getCv1Dest() const { return cv1_dest; }
|
||||||
|
CvDestination getCv2Dest() const { return cv2_dest; }
|
||||||
|
|
||||||
|
// Getters (Get the BASE value for editing or cv modded value for display)
|
||||||
|
|
||||||
|
int getProbability(bool withCvMod = false) const {
|
||||||
|
return withCvMod ? cvmod_probability : base_probability;
|
||||||
|
}
|
||||||
|
int getDutyCycle(bool withCvMod = false) const {
|
||||||
|
return withCvMod ? cvmod_duty_cycle : base_duty_cycle;
|
||||||
|
}
|
||||||
|
int getOffset(bool withCvMod = false) const {
|
||||||
|
return withCvMod ? cvmod_offset : base_offset;
|
||||||
|
}
|
||||||
|
int getSwing(bool withCvMod = false) const {
|
||||||
|
return withCvMod ? cvmod_swing : base_swing;
|
||||||
|
}
|
||||||
|
int getClockMod(bool withCvMod = false) const {
|
||||||
|
return pgm_read_word_near(&CLOCK_MOD[getClockModIndex(withCvMod)]);
|
||||||
|
}
|
||||||
|
int getClockModIndex(bool withCvMod = false) const {
|
||||||
|
return withCvMod ? cvmod_clock_mod_index : base_clock_mod_index;
|
||||||
|
}
|
||||||
|
bool isCvModActive() const {
|
||||||
|
return cv1_dest != CV_DEST_NONE || cv2_dest != CV_DEST_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleMute() { mute = !mute; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Processes a clock tick and determines if the output should be high
|
||||||
|
* or low. Note: this method is called from an ISR and must be kept as simple
|
||||||
|
* as possible.
|
||||||
|
* @param tick The current clock tick count.
|
||||||
|
* @param output The output object to be modified.
|
||||||
|
*/
|
||||||
|
void processClockTick(uint32_t tick, DigitalOutput &output) {
|
||||||
|
// Mute check
|
||||||
|
if (mute) {
|
||||||
|
output.Low();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Init() {
|
const uint16_t mod_pulses =
|
||||||
// Reset base values to their defaults
|
pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]);
|
||||||
base_clock_mod_index = DEFAULT_CLOCK_MOD_INDEX;
|
|
||||||
base_probability = 100;
|
|
||||||
base_duty_cycle = 50;
|
|
||||||
base_offset = 0;
|
|
||||||
base_swing = 50;
|
|
||||||
|
|
||||||
cv1_dest = CV_DEST_NONE;
|
// Conditionally apply swing on down beats.
|
||||||
cv2_dest = CV_DEST_NONE;
|
uint16_t swing_pulses = 0;
|
||||||
|
if (_swing_pulse_amount > 0 && (tick / mod_pulses) % 2 == 1) {
|
||||||
pattern.Init(DEFAULT_PATTERN);
|
swing_pulses = _swing_pulse_amount;
|
||||||
|
|
||||||
// Calcule the clock mod pulses on init.
|
|
||||||
_recalculatePulses();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isCvModActive() const { return cv1_dest != CV_DEST_NONE || cv2_dest != CV_DEST_NONE; }
|
// Duty cycle high check logic
|
||||||
|
const uint32_t current_tick_offset = tick + _offset_pulses + swing_pulses;
|
||||||
// Setters (Set the BASE value)
|
if (!output.On()) {
|
||||||
|
// Step check
|
||||||
void setClockMod(int index) {
|
if (current_tick_offset % mod_pulses == 0) {
|
||||||
base_clock_mod_index = constrain(index, 0, MOD_CHOICE_SIZE - 1);
|
bool hit = cvmod_probability >= random(0, 100);
|
||||||
}
|
if (hit) {
|
||||||
|
output.High();
|
||||||
void setProbability(int prob) {
|
|
||||||
base_probability = constrain(prob, 0, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
void setDutyCycle(int duty) {
|
|
||||||
base_duty_cycle = constrain(duty, 1, 99);
|
|
||||||
}
|
|
||||||
|
|
||||||
void setOffset(int off) {
|
|
||||||
base_offset = constrain(off, 0, 99);
|
|
||||||
}
|
|
||||||
void setSwing(int val) {
|
|
||||||
base_swing = constrain(val, 50, 95);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Euclidean
|
|
||||||
void setSteps(int val) {
|
|
||||||
pattern.SetSteps(val);
|
|
||||||
}
|
|
||||||
void setHits(int val) {
|
|
||||||
pattern.SetHits(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
void setCv1Dest(CvDestination dest) {
|
|
||||||
cv1_dest = dest;
|
|
||||||
_recalculatePulses();
|
|
||||||
}
|
|
||||||
void setCv2Dest(CvDestination dest) {
|
|
||||||
cv2_dest = dest;
|
|
||||||
_recalculatePulses();
|
|
||||||
}
|
|
||||||
CvDestination getCv1Dest() const { return cv1_dest; }
|
|
||||||
CvDestination getCv2Dest() const { return cv2_dest; }
|
|
||||||
|
|
||||||
// Getters (Get the BASE value for editing or cv modded value for display)
|
|
||||||
int getProbability() const { return base_probability; }
|
|
||||||
int getDutyCycle() const { return base_duty_cycle; }
|
|
||||||
int getOffset() const { return base_offset; }
|
|
||||||
int getSwing() const { return base_swing; }
|
|
||||||
int getClockMod() const { return pgm_read_word_near(&CLOCK_MOD[getClockModIndex()]); }
|
|
||||||
int getClockModIndex() const { return base_clock_mod_index; }
|
|
||||||
byte getSteps() const { return pattern.GetSteps(); }
|
|
||||||
byte getHits() const { return pattern.GetHits(); }
|
|
||||||
|
|
||||||
// Getters that calculate the value with CV modulation applied.
|
|
||||||
int getClockModIndexWithMod(int cv1_val, int cv2_val) {
|
|
||||||
int clock_mod_index = _calculateMod(CV_DEST_MOD, cv1_val, cv2_val, -(MOD_CHOICE_SIZE / 2), MOD_CHOICE_SIZE / 2);
|
|
||||||
return constrain(base_clock_mod_index + clock_mod_index, 0, MOD_CHOICE_SIZE - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
int getClockModWithMod(int cv1_val, int cv2_val) {
|
|
||||||
int clock_mod = _calculateMod(CV_DEST_MOD, cv1_val, cv2_val, -(MOD_CHOICE_SIZE / 2), MOD_CHOICE_SIZE / 2);
|
|
||||||
return pgm_read_word_near(&CLOCK_MOD[getClockModIndexWithMod(cv1_val, cv2_val)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
int getProbabilityWithMod(int cv1_val, int cv2_val) {
|
|
||||||
int prob_mod = _calculateMod(CV_DEST_PROB, cv1_val, cv2_val, -50, 50);
|
|
||||||
return constrain(base_probability + prob_mod, 0, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
int getDutyCycleWithMod(int cv1_val, int cv2_val) {
|
|
||||||
int duty_mod = _calculateMod(CV_DEST_DUTY, cv1_val, cv2_val, -50, 50);
|
|
||||||
return constrain(base_duty_cycle + duty_mod, 1, 99);
|
|
||||||
}
|
|
||||||
|
|
||||||
int getOffsetWithMod(int cv1_val, int cv2_val) {
|
|
||||||
int offset_mod = _calculateMod(CV_DEST_OFFSET, cv1_val, cv2_val, -50, 50);
|
|
||||||
return constrain(base_offset + offset_mod, 0, 99);
|
|
||||||
}
|
|
||||||
|
|
||||||
int getSwingWithMod(int cv1_val, int cv2_val) {
|
|
||||||
int swing_mod = _calculateMod(CV_DEST_SWING, cv1_val, cv2_val, -25, 25);
|
|
||||||
return constrain(base_swing + swing_mod, 50, 95);
|
|
||||||
}
|
|
||||||
|
|
||||||
byte getStepsWithMod(int cv1_val, int cv2_val) {
|
|
||||||
int step_mod = _calculateMod(CV_DEST_EUC_STEPS, cv1_val, cv2_val, 0, MAX_PATTERN_LEN);
|
|
||||||
return constrain(pattern.GetSteps() + step_mod, 1, MAX_PATTERN_LEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
byte getHitsWithMod(int cv1_val, int cv2_val) {
|
|
||||||
// The number of hits is dependent on the modulated number of steps.
|
|
||||||
byte modulated_steps = getStepsWithMod(cv1_val, cv2_val);
|
|
||||||
int hit_mod = _calculateMod(CV_DEST_EUC_HITS, cv1_val, cv2_val, 0, modulated_steps);
|
|
||||||
return constrain(pattern.GetHits() + hit_mod, 1, modulated_steps);
|
|
||||||
}
|
|
||||||
|
|
||||||
void toggleMute() { mute = !mute; }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Processes a clock tick and determines if the output should be high or low.
|
|
||||||
* Note: this method is called from an ISR and must be kept as simple as possible.
|
|
||||||
* @param tick The current clock tick count.
|
|
||||||
* @param output The output object to be modified.
|
|
||||||
*/
|
|
||||||
void processClockTick(uint32_t tick, DigitalOutput& output) {
|
|
||||||
// Mute check
|
|
||||||
if (mute) {
|
|
||||||
output.Low();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCvModActive()) _recalculatePulses();
|
|
||||||
|
|
||||||
int cv1 = gravity.cv1.Read();
|
|
||||||
int cv2 = gravity.cv2.Read();
|
|
||||||
int cvmod_clock_mod_index = getClockModIndexWithMod(cv1, cv2);
|
|
||||||
int cvmod_probability = getProbabilityWithMod(cv1, cv2);
|
|
||||||
|
|
||||||
const uint16_t mod_pulses = pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]);
|
|
||||||
|
|
||||||
// Conditionally apply swing on down beats.
|
|
||||||
uint16_t swing_pulses = 0;
|
|
||||||
if (_swing_pulse_amount > 0 && (tick / mod_pulses) % 2 == 1) {
|
|
||||||
swing_pulses = _swing_pulse_amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duty cycle high check logic
|
|
||||||
const uint32_t current_tick_offset = tick + _offset_pulses + swing_pulses;
|
|
||||||
if (!output.On()) {
|
|
||||||
// Step check
|
|
||||||
if (current_tick_offset % mod_pulses == 0) {
|
|
||||||
bool hit = cvmod_probability >= random(0, 100);
|
|
||||||
// Euclidean rhythm hit check
|
|
||||||
switch (pattern.NextStep()) {
|
|
||||||
case Pattern::REST: // Rest when active or fall back to probability
|
|
||||||
hit = false;
|
|
||||||
break;
|
|
||||||
case Pattern::HIT: // Hit if probability is true
|
|
||||||
hit &= true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (hit) {
|
|
||||||
output.High();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duty cycle low check
|
|
||||||
const uint32_t duty_cycle_end_tick = tick + _duty_pulses + _offset_pulses + swing_pulses;
|
|
||||||
if (duty_cycle_end_tick % mod_pulses == 0) {
|
|
||||||
output.Low();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
// Duty cycle low check
|
||||||
int _calculateMod(CvDestination dest, int cv1_val, int cv2_val, int min_range, int max_range) {
|
const uint32_t duty_cycle_end_tick =
|
||||||
int mod1 = (cv1_dest == dest) ? map(cv1_val, -512, 512, min_range, max_range) : 0;
|
tick + _duty_pulses + _offset_pulses + swing_pulses;
|
||||||
int mod2 = (cv2_dest == dest) ? map(cv2_val, -512, 512, min_range, max_range) : 0;
|
if (duty_cycle_end_tick % mod_pulses == 0) {
|
||||||
return mod1 + mod2;
|
output.Low();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @brief Calculate and store cv modded values using bipolar mapping.
|
||||||
|
* Default to base value if not the current CV destination.
|
||||||
|
*
|
||||||
|
* @param cv1_val analog input reading for cv1
|
||||||
|
* @param cv2_val analog input reading for cv2
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
void applyCvMod(int cv1_val, int cv2_val) {
|
||||||
|
// Note: This is optimized for cpu performance. This method is called
|
||||||
|
// from the main loop and stores the cv mod values. This reduces CPU
|
||||||
|
// cycles inside the internal clock interrupt, which is preferrable.
|
||||||
|
// However, if RAM usage grows too much, we have an opportunity to
|
||||||
|
// refactor this to store just the CV read values, and calculate the
|
||||||
|
// cv mod value per channel inside the getter methods by passing cv
|
||||||
|
// values. This would reduce RAM usage, but would introduce a
|
||||||
|
// significant CPU cost, which may have undesirable performance issues.
|
||||||
|
if (!isCvModActive()) {
|
||||||
|
cvmod_clock_mod_index = base_clock_mod_index;
|
||||||
|
cvmod_probability = base_clock_mod_index;
|
||||||
|
cvmod_duty_cycle = base_clock_mod_index;
|
||||||
|
cvmod_offset = base_clock_mod_index;
|
||||||
|
cvmod_swing = base_clock_mod_index;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _recalculatePulses() {
|
int dest_mod = _calculateMod(CV_DEST_MOD, cv1_val, cv2_val,
|
||||||
int cv1 = gravity.cv1.Read();
|
-(MOD_CHOICE_SIZE / 2), MOD_CHOICE_SIZE / 2);
|
||||||
int cv2 = gravity.cv2.Read();
|
cvmod_clock_mod_index = constrain(base_clock_mod_index + dest_mod, 0, 100);
|
||||||
int clock_mod_index = getClockModIndexWithMod(cv1, cv2);
|
|
||||||
int duty_cycle = getDutyCycleWithMod(cv1, cv2);
|
|
||||||
int offset = getOffsetWithMod(cv1, cv2);
|
|
||||||
int swing = getSwingWithMod(cv1, cv2);
|
|
||||||
const uint16_t mod_pulses = pgm_read_word_near(&CLOCK_MOD_PULSES[clock_mod_index]);
|
|
||||||
_duty_pulses = max((long)((mod_pulses * (100L - duty_cycle)) / 100L), 1L);
|
|
||||||
_offset_pulses = (long)((mod_pulses * (100L - offset)) / 100L);
|
|
||||||
|
|
||||||
// Calculate the down beat swing amount.
|
int prob_mod = _calculateMod(CV_DEST_PROB, cv1_val, cv2_val, -50, 50);
|
||||||
if (swing > 50) {
|
cvmod_probability = constrain(base_probability + prob_mod, 0, 100);
|
||||||
int shifted_swing = swing - 50;
|
|
||||||
_swing_pulse_amount = (long)((mod_pulses * (100L - shifted_swing)) / 100L);
|
int duty_mod = _calculateMod(CV_DEST_DUTY, cv1_val, cv2_val, -50, 50);
|
||||||
} else {
|
cvmod_duty_cycle = constrain(base_duty_cycle + duty_mod, 1, 99);
|
||||||
_swing_pulse_amount = 0;
|
|
||||||
}
|
int offset_mod = _calculateMod(CV_DEST_OFFSET, cv1_val, cv2_val, -50, 50);
|
||||||
|
cvmod_offset = constrain(base_offset + offset_mod, 0, 99);
|
||||||
|
|
||||||
|
int swing_mod = _calculateMod(CV_DEST_SWING, cv1_val, cv2_val, -25, 25);
|
||||||
|
cvmod_swing = constrain(base_swing + swing_mod, 50, 95);
|
||||||
|
|
||||||
|
// After all cvmod values are updated, recalculate clock pulse modifiers.
|
||||||
|
_recalculatePulses();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
int _calculateMod(CvDestination dest, int cv1_val, int cv2_val, int min_range,
|
||||||
|
int max_range) {
|
||||||
|
int mod1 =
|
||||||
|
(cv1_dest == dest) ? map(cv1_val, -512, 512, min_range, max_range) : 0;
|
||||||
|
int mod2 =
|
||||||
|
(cv2_dest == dest) ? map(cv2_val, -512, 512, min_range, max_range) : 0;
|
||||||
|
return mod1 + mod2;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recalculatePulses() {
|
||||||
|
const uint16_t mod_pulses =
|
||||||
|
pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]);
|
||||||
|
_duty_pulses =
|
||||||
|
max((long)((mod_pulses * (100L - cvmod_duty_cycle)) / 100L), 1L);
|
||||||
|
_offset_pulses = (long)((mod_pulses * (100L - cvmod_offset)) / 100L);
|
||||||
|
|
||||||
|
// Calculate the down beat swing amount.
|
||||||
|
if (cvmod_swing > 50) {
|
||||||
|
int shifted_swing = cvmod_swing - 50;
|
||||||
|
_swing_pulse_amount =
|
||||||
|
(long)((mod_pulses * (100L - shifted_swing)) / 100L);
|
||||||
|
} else {
|
||||||
|
_swing_pulse_amount = 0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// User-settable base values.
|
// User-settable base values.
|
||||||
byte base_clock_mod_index;
|
byte base_clock_mod_index;
|
||||||
byte base_probability;
|
byte base_probability;
|
||||||
byte base_duty_cycle;
|
byte base_duty_cycle;
|
||||||
byte base_offset;
|
byte base_offset;
|
||||||
byte base_swing;
|
byte base_swing;
|
||||||
|
|
||||||
// CV mod configuration
|
// Base value with cv mod applied.
|
||||||
CvDestination cv1_dest;
|
byte cvmod_clock_mod_index;
|
||||||
CvDestination cv2_dest;
|
byte cvmod_probability;
|
||||||
|
byte cvmod_duty_cycle;
|
||||||
|
byte cvmod_offset;
|
||||||
|
byte cvmod_swing;
|
||||||
|
|
||||||
// Euclidean pattern
|
// CV mod configuration
|
||||||
Pattern pattern;
|
CvDestination cv1_dest;
|
||||||
|
CvDestination cv2_dest;
|
||||||
|
|
||||||
// Mute channel flag
|
// Mute channel flag
|
||||||
bool mute;
|
bool mute;
|
||||||
|
|
||||||
// Pre-calculated pulse values for ISR performance
|
// Pre-calculated pulse values for ISR performance
|
||||||
uint16_t _duty_pulses;
|
uint16_t _duty_pulses;
|
||||||
uint16_t _offset_pulses;
|
uint16_t _offset_pulses;
|
||||||
uint16_t _swing_pulse_amount;
|
uint16_t _swing_pulse_amount;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // CHANNEL_H
|
#endif // CHANNEL_H
|
||||||
@ -29,17 +29,24 @@
|
|||||||
const uint8_t TEXT_FONT[437] U8G2_FONT_SECTION("velvetscreen") PROGMEM =
|
const uint8_t TEXT_FONT[437] U8G2_FONT_SECTION("velvetscreen") PROGMEM =
|
||||||
"\64\0\2\2\3\3\2\3\4\5\5\0\0\5\0\5\0\0\221\0\0\1\230 \4\200\134%\11\255tT"
|
"\64\0\2\2\3\3\2\3\4\5\5\0\0\5\0\5\0\0\221\0\0\1\230 \4\200\134%\11\255tT"
|
||||||
"R\271RI(\6\252\334T\31)\7\252\134bJ\12+\7\233\345\322J\0,\5\221T\4-\5\213"
|
"R\271RI(\6\252\334T\31)\7\252\134bJ\12+\7\233\345\322J\0,\5\221T\4-\5\213"
|
||||||
"f\6.\5\211T\2/\6\244\354c\33\60\10\254\354T\64\223\2\61\7\353\354\222\254\6\62\11\254l"
|
"f\6.\5\211T\2/"
|
||||||
"\66J*\217\0\63\11\254l\66J\32\215\4\64\10\254l\242\34\272\0\65\11\254l\206\336h$\0\66"
|
"\6\244\354c\33\60\10\254\354T\64\223\2\61\7\353\354\222\254\6\62\11\254l"
|
||||||
"\11\254\354T^\61)\0\67\10\254lF\216u\4\70\11\254\354TL*&\5\71\11\254\354TL;"
|
"\66J*"
|
||||||
|
"\217\0\63\11\254l\66J\32\215\4\64\10\254l\242\34\272\0\65\11\254l\206\336h"
|
||||||
|
"$\0\66"
|
||||||
|
"\11\254\354T^\61)\0\67\10\254lF\216u\4\70\11\254\354TL*&"
|
||||||
|
"\5\71\11\254\354TL;"
|
||||||
")\0:\6\231UR\0A\10\254\354T\34S\6B\11\254lV\34)\216\4C\11\254\354T\324\61"
|
")\0:\6\231UR\0A\10\254\354T\34S\6B\11\254lV\34)\216\4C\11\254\354T\324\61"
|
||||||
")\0D\10\254lV\64G\2E\10\254l\206\36z\4F\10\254l\206^\71\3G\11\254\354TN"
|
")\0D\10\254lV\64G\2E\10\254l\206\36z\4F\10\254l\206^\71\3G\11\254\354TN"
|
||||||
"\63)\0H\10\254l\242\34S\6I\6\251T\206\0J\10\254\354k\231\24\0K\11\254l\242J\62"
|
"\63)"
|
||||||
|
"\0H\10\254l\242\34S\6I\6\251T\206\0J\10\254\354k\231\24\0K\11\254l\242J\62"
|
||||||
"\225\1L\7\254lr{\4M\11\255t\362ZI\353\0N\11\255t\362TI\356\0O\10\254\354T"
|
"\225\1L\7\254lr{\4M\11\255t\362ZI\353\0N\11\255t\362TI\356\0O\10\254\354T"
|
||||||
"\64\223\2P\11\254lV\34)g\0Q\10\254\354T\264b\12R\10\254lV\34\251\31S\11\254\354"
|
"\64\223\2P\11\254lV\34)"
|
||||||
|
"g\0Q\10\254\354T\264b\12R\10\254lV\34\251\31S\11\254\354"
|
||||||
"FF\32\215\4T\7\253dVl\1U\10\254l\242\63)\0V\11\255t\262Ne\312\21W\12\255"
|
"FF\32\215\4T\7\253dVl\1U\10\254l\242\63)\0V\11\255t\262Ne\312\21W\12\255"
|
||||||
"t\262J*\251.\0X\11\254l\242L*\312\0Y\12\255tr\252\63\312(\2Z\7\253df*"
|
"t\262J*\251.\0X\11\254l\242L*\312\0Y\12\255tr\252\63\312(\2Z\7\253df*"
|
||||||
"\7p\10\255\364V\266\323\2q\7\255\364\216\257\5r\10\253d\242\32*\2t\6\255t\376#w\11"
|
"\7p\10\255\364V\266\323\2q\7\255\364\216\257\5r\10\253d\242\32*"
|
||||||
|
"\2t\6\255t\376#w\11"
|
||||||
"\255\364V\245FN\13x\6\233dR\7\0\0\0\4\377\377\0";
|
"\255\364V\245FN\13x\6\233dR\7\0\0\0\4\377\377\0";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -47,42 +54,63 @@ const uint8_t TEXT_FONT[437] U8G2_FONT_SECTION("velvetscreen") PROGMEM =
|
|||||||
* https://stncrn.github.io/u8g2-unifont-helper/
|
* https://stncrn.github.io/u8g2-unifont-helper/
|
||||||
* "%/0123456789ABCDEFILNORSTUVXx"
|
* "%/0123456789ABCDEFILNORSTUVXx"
|
||||||
*/
|
*/
|
||||||
const uint8_t LARGE_FONT[766] U8G2_FONT_SECTION("stk-l") PROGMEM =
|
const uint8_t LARGE_FONT[766] U8G2_FONT_SECTION("stk-l") =
|
||||||
"\35\0\4\4\4\5\3\1\6\20\30\0\0\27\0\0\0\1\77\0\0\2\341%'\17;\226\261\245FL"
|
"\35\0\4\4\4\5\3\1\6\20\30\0\0\27\0\0\0\1\77\0\0\2\341%'\17;\226\261\245FL"
|
||||||
"\64B\214\30\22\223\220)Bj\10Q\232\214\42R\206\310\210\21d\304\30\32a\254\304\270!\0/\14"
|
"\64B\214\30\22\223\220)"
|
||||||
"\272\272\275\311H\321g\343\306\1\60\37|\373\35CJT\20:fW\207\320\210\60\42\304\204\30D\247"
|
"Bj\10Q\232\214\42R\206\310\210\21d\304\30\32a\254\304\270!\0/\14"
|
||||||
"\214\331\354\20\11%\212\314\0\61\24z\275\245a\244\12\231\71\63b\214\220q\363\377(E\6\62\33|"
|
"\272\272\275\311H\321g\343\306\1\60\37|\373\35CJT\20:"
|
||||||
"\373\35ShT\20:fl\344\14\211\231\301\306T\71\202#g\371\340\201\1\63\34|\373\35ShT"
|
"fW\207\320\210\60\42\304\204\30D\247"
|
||||||
"\20:fl\344@r\264\263\222\344,\215\35\42\241\6\225\31\0\64 |\373-!\203\206\214!\62\204"
|
"\214\331\354\20\11%"
|
||||||
"\314\220A#\10\215\30\65b\324\210Q\306\354\354\1\213\225\363\1\65\32|\373\15\25[\214\234/\10)"
|
"\212\314\0\61\24z\275\245a\244\12\231\71\63b\214\220q\363\377(E\6\62\33|"
|
||||||
|
"\373\35ShT\20:fl\344\14\211\231\301\306T\71\202#g\371\340\201\1\63\34|"
|
||||||
|
"\373\35ShT"
|
||||||
|
"\20:fl\344@r\264\263\222\344,\215\35\42\241\6\225\31\0\64 "
|
||||||
|
"|\373-!\203\206\214!\62\204"
|
||||||
|
"\314\220A#\10\215\30\65b\324\210Q\306\354\354\1\213\225\363\1\65\32|"
|
||||||
|
"\373\15\25[\214\234/\10)"
|
||||||
"Y\61j\350\310Y\32;DB\15*\63\0\66\33}\33\236SiV\14;gt^\230Y\302\202\324"
|
"Y\61j\350\310Y\32;DB\15*\63\0\66\33}\33\236SiV\14;gt^\230Y\302\202\324"
|
||||||
"\71\273;EbM\252\63\0\67\23|\373\205\25\17R\316\207\344\350p\312\201#\347\35\0\70 |\373"
|
"\71\273;EbM\252\63\0\67\23|\373\205\25\17R\316\207\344\350p\312\201#"
|
||||||
"\35ShT\20:f\331!\22D\310 :\205\206\10\11B\307\354\354\20\11\65\250\314\0\71\32|\373"
|
"\347\35\0\70 |\373"
|
||||||
|
"\35ShT\20:f\331!\22D\310 "
|
||||||
|
":\205\206\10\11B\307\354\354\20\11\65\250\314\0\71\32|\373"
|
||||||
"\35ShT\20:fg\207H,Q\223r\276\30DB\15*\63\0A\26}\33\246r\247\322P\62"
|
"\35ShT\20:fg\207H,Q\223r\276\30DB\15*\63\0A\26}\33\246r\247\322P\62"
|
||||||
"j\310\250\21\343\354\335\203\357\354w\3B$}\33\206Dj\226\214\42\61l\304\260\21\303F\14\33\61"
|
"j\310\250\21\343\354\335\203\357\354w\3B$}"
|
||||||
"\212\304\222MF\221\30v\316\236=\10\301b\11\0C\27}\33\236Si\226\20Bft\376O\211\215"
|
"\33\206Dj\226\214\42\61l\304\260\21\303F\14\33\61"
|
||||||
" Db\215\42$\0D\33}\33\206Dj\226\214\32\62l\304\260\21\343\354\177vl\304(\22K\324"
|
"\212\304\222MF\221\30v\316\236=\10\301b\11\0C\27}"
|
||||||
"$\2E\22|\373\205\17R\316KD\30\215\234_>x`\0F\20|\373\205\17R\316\227i\262\31"
|
"\33\236Si\226\20Bft\376O\211\215"
|
||||||
"\71\377\22\0I\7s\333\204\77HL\15{\333\205\201\363\377\77|\360`\0N$}\33\6\201\346\314"
|
" Db\215\42$\0D\33}\33\206Dj\226\214\32\62l\304\260\21\343\354\177vl\304("
|
||||||
"\35;\206\12U\242D&\306\230\30cd\210\221!fF\230\31a(+\314\256\63\67\0O\26}\33"
|
"\22K\324"
|
||||||
"\236Si\226\214\32\61\316\376\277\33\61j\310\232Tg\0R\61\216;\6Ek\230\14#\61n\304\270"
|
"$\2E\22|\373\205\17R\316KD\30\215\234_>x`\0F\20|"
|
||||||
"\21\343F\214\33\61n\304\60\22\243\210\60Q\224j\310\260\61\243\306\20\232\325\230QD\206\221\30\67b"
|
"\373\205\17R\316\227i\262\31"
|
||||||
"\334\301\1S\42\216;\236c\211\226\220\42\61n\304\270\21c\307R\232,[\262\203\307\216\65h\16\25"
|
"\71\377\22\0I\7s\333\204\77HL\15{\333\205\201\363\377\77|\360`\0N$}"
|
||||||
"\21&\253\320\0T\15}\33\206\17R\15\235\377\377\25\0U\21|\373\205a\366\377\237\215\30\64D\15"
|
"\33\6\201\346\314"
|
||||||
"*\63\0V\26\177\371\205\221\366\377\313\21\343\206\220\42C\25\11r'\313\16\3X)~;\206\201\6"
|
"\35;\206\12U\242D&\306\230\30cd\210\221!fF\230\31a(+\314\256\63\67\0O\26}"
|
||||||
"\217\221\30\66\204\20\31\42\244\206\14Cg\320$Q\222\6\315!\33\62\212\10\31BD\206\215 v\320"
|
"\33"
|
||||||
"\302\1x\24\312\272\205A\206\216\220@c\212\224\31$S\14\262h\0\0\0\0\4\377\377\0";
|
"\236Si\226\214\32\61\316\376\277\33\61j\310\232Tg\0R\61\216;\6Ek\230\14#"
|
||||||
|
"\61n\304\270"
|
||||||
|
"\21\343F\214\33\61n\304\60\22\243\210\60Q\224j\310\260\61\243\306\20\232"
|
||||||
|
"\325\230QD\206\221\30\67b"
|
||||||
|
"\334\301\1S\42\216;\236c\211\226\220\42\61n\304\270\21c\307R\232,["
|
||||||
|
"\262\203\307\216\65h\16\25"
|
||||||
|
"\21&\253\320\0T\15}\33\206\17R\15\235\377\377\25\0U\21|"
|
||||||
|
"\373\205a\366\377\237\215\30\64D\15"
|
||||||
|
"*\63\0V\26\177\371\205\221\366\377\313\21\343\206\220\42C\25\11r'"
|
||||||
|
"\313\16\3X)~;\206\201\6"
|
||||||
|
"\217\221\30\66\204\20\31\42\244\206\14Cg\320$Q\222\6\315!"
|
||||||
|
"\33\62\212\10\31BD\206\215 v\320"
|
||||||
|
"\302\1x\24\312\272\205A\206\216\220@c\212\224\31$"
|
||||||
|
"S\14\262h\0\0\0\0\4\377\377\0";
|
||||||
|
|
||||||
#define play_icon_width 14
|
#define play_icon_width 14
|
||||||
#define play_icon_height 14
|
#define play_icon_height 14
|
||||||
static const unsigned char play_icon[28] PROGMEM = {
|
static const unsigned char play_icon[28] PROGMEM = {
|
||||||
0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x7C, 0x00, 0xFC, 0x00, 0xFC, 0x03,
|
0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x7C, 0x00, 0xFC, 0x00,
|
||||||
0xFC, 0x0F, 0xFC, 0x0F, 0xFC, 0x03, 0xFC, 0x00, 0x7C, 0x00, 0x3C, 0x00,
|
0xFC, 0x03, 0xFC, 0x0F, 0xFC, 0x0F, 0xFC, 0x03, 0xFC, 0x00,
|
||||||
0x00, 0x00, 0x00, 0x00};
|
0x7C, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||||
static const unsigned char pause_icon[28] PROGMEM = {
|
static const unsigned char pause_icon[28] PROGMEM = {
|
||||||
0x00, 0x00, 0x00, 0x00, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E,
|
0x00, 0x00, 0x00, 0x00, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E,
|
||||||
0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E,
|
0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E,
|
||||||
0x38, 0x0E, 0x00, 0x00};
|
0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x00, 0x00};
|
||||||
|
|
||||||
// Constants for screen layout and fonts
|
// Constants for screen layout and fonts
|
||||||
constexpr uint8_t SCREEN_CENTER_X = 32;
|
constexpr uint8_t SCREEN_CENTER_X = 32;
|
||||||
@ -98,114 +126,101 @@ constexpr uint8_t CHANNEL_BOX_HEIGHT = 14;
|
|||||||
|
|
||||||
// Menu items for editing global parameters.
|
// Menu items for editing global parameters.
|
||||||
enum ParamsMainPage : uint8_t {
|
enum ParamsMainPage : uint8_t {
|
||||||
PARAM_MAIN_TEMPO,
|
PARAM_MAIN_TEMPO,
|
||||||
PARAM_MAIN_SOURCE,
|
PARAM_MAIN_SOURCE,
|
||||||
PARAM_MAIN_RUN,
|
PARAM_MAIN_RUN,
|
||||||
PARAM_MAIN_RESET,
|
PARAM_MAIN_RESET,
|
||||||
PARAM_MAIN_PULSE,
|
PARAM_MAIN_PULSE,
|
||||||
PARAM_MAIN_ENCODER_DIR,
|
PARAM_MAIN_ENCODER_DIR,
|
||||||
PARAM_MAIN_SAVE_DATA,
|
PARAM_MAIN_ROTATE_DISP,
|
||||||
PARAM_MAIN_LOAD_DATA,
|
PARAM_MAIN_SAVE_DATA,
|
||||||
PARAM_MAIN_FACTORY_RESET,
|
PARAM_MAIN_LOAD_DATA,
|
||||||
PARAM_MAIN_LAST,
|
PARAM_MAIN_RESET_STATE,
|
||||||
|
PARAM_MAIN_FACTORY_RESET,
|
||||||
|
PARAM_MAIN_LAST,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Menu items for editing channel parameters.
|
// Menu items for editing channel parameters.
|
||||||
enum ParamsChannelPage : uint8_t {
|
enum ParamsChannelPage : uint8_t {
|
||||||
PARAM_CH_MOD,
|
PARAM_CH_MOD,
|
||||||
PARAM_CH_PROB,
|
PARAM_CH_PROB,
|
||||||
PARAM_CH_DUTY,
|
PARAM_CH_DUTY,
|
||||||
PARAM_CH_OFFSET,
|
PARAM_CH_OFFSET,
|
||||||
PARAM_CH_SWING,
|
PARAM_CH_SWING,
|
||||||
PARAM_CH_EUC_STEPS,
|
PARAM_CH_CV1_DEST,
|
||||||
PARAM_CH_EUC_HITS,
|
PARAM_CH_CV2_DEST,
|
||||||
PARAM_CH_CV1_DEST,
|
PARAM_CH_LAST,
|
||||||
PARAM_CH_CV2_DEST,
|
|
||||||
PARAM_CH_LAST,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Common/resused strings stored as const to save on flash memory.
|
|
||||||
const char* const STR_24_PPQN = "24 PPQN";
|
|
||||||
const char* const STR_4_PPQN = "4 PPQN";
|
|
||||||
const char* const STR_1_PPQN = "1 PPQN";
|
|
||||||
const char* const STR_CV_1 = "CV 1";
|
|
||||||
const char* const STR_CV_2 = "CV 2";
|
|
||||||
const char* const STR_NONE = "NONE";
|
|
||||||
const char* const STR_EXT = "EXT";
|
|
||||||
const char* const STR_X = "X";
|
|
||||||
const char* const STR_DEFAULT = "DEFAULT";
|
|
||||||
const char* const STR_REVERSED = "REVERSED";
|
|
||||||
const char* const STR_FLIPPED = "FLIPPED";
|
|
||||||
const char* const STR_BACK = "BACK TO MAIN";
|
|
||||||
const char* const STR_EUC_STEPS = "EUCLID STEPS";
|
|
||||||
const char* const STR_EUC_HITS = "EUCLID HITS";
|
|
||||||
|
|
||||||
// Helper function to draw centered text
|
// Helper function to draw centered text
|
||||||
void drawCenteredText(const char* text, int y, const uint8_t* font) {
|
void drawCenteredText(const char *text, int y, const uint8_t *font) {
|
||||||
gravity.display.setFont(font);
|
gravity.display.setFont(font);
|
||||||
int textWidth = gravity.display.getStrWidth(text);
|
int textWidth = gravity.display.getStrWidth(text);
|
||||||
gravity.display.drawStr(SCREEN_CENTER_X - (textWidth / 2), y, text);
|
gravity.display.drawStr(SCREEN_CENTER_X - (textWidth / 2), y, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to draw right-aligned text
|
// Helper function to draw right-aligned text
|
||||||
void drawRightAlignedText(const char* text, int y) {
|
void drawRightAlignedText(const char *text, int y) {
|
||||||
int textWidth = gravity.display.getStrWidth(text);
|
int textWidth = gravity.display.getStrWidth(text);
|
||||||
int drawX = (SCREEN_WIDTH - textWidth) - MENU_BOX_PADDING;
|
int drawX = (SCREEN_WIDTH - textWidth) - MENU_BOX_PADDING;
|
||||||
gravity.display.drawStr(drawX, y, text);
|
gravity.display.drawStr(drawX, y, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
void drawMainSelection() {
|
void drawMainSelection() {
|
||||||
gravity.display.setDrawColor(1);
|
gravity.display.setDrawColor(1);
|
||||||
const int tickSize = 3;
|
const int tickSize = 3;
|
||||||
const int mainWidth = SCREEN_WIDTH / 2;
|
const int mainWidth = SCREEN_WIDTH / 2;
|
||||||
const int mainHeight = 49;
|
const int mainHeight = 49;
|
||||||
gravity.display.drawLine(0, 0, tickSize, 0);
|
gravity.display.drawLine(0, 0, tickSize, 0);
|
||||||
gravity.display.drawLine(0, 0, 0, tickSize);
|
gravity.display.drawLine(0, 0, 0, tickSize);
|
||||||
gravity.display.drawLine(mainWidth, 0, mainWidth - tickSize, 0);
|
gravity.display.drawLine(mainWidth, 0, mainWidth - tickSize, 0);
|
||||||
gravity.display.drawLine(mainWidth, 0, mainWidth, tickSize);
|
gravity.display.drawLine(mainWidth, 0, mainWidth, tickSize);
|
||||||
gravity.display.drawLine(mainWidth, mainHeight, mainWidth, mainHeight - tickSize);
|
gravity.display.drawLine(mainWidth, mainHeight, mainWidth,
|
||||||
gravity.display.drawLine(mainWidth, mainHeight, mainWidth - tickSize, mainHeight);
|
mainHeight - tickSize);
|
||||||
gravity.display.drawLine(0, mainHeight, tickSize, mainHeight);
|
gravity.display.drawLine(mainWidth, mainHeight, mainWidth - tickSize,
|
||||||
gravity.display.drawLine(0, mainHeight, 0, mainHeight - tickSize);
|
mainHeight);
|
||||||
gravity.display.setDrawColor(2);
|
gravity.display.drawLine(0, mainHeight, tickSize, mainHeight);
|
||||||
|
gravity.display.drawLine(0, mainHeight, 0, mainHeight - tickSize);
|
||||||
|
gravity.display.setDrawColor(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
void drawMenuItems(String menu_items[], int menu_size) {
|
void drawMenuItems(String menu_items[], int menu_size) {
|
||||||
// Draw menu items
|
// Draw menu items
|
||||||
gravity.display.setFont(TEXT_FONT);
|
gravity.display.setFont(TEXT_FONT);
|
||||||
|
|
||||||
// Draw selected menu item box
|
// Draw selected menu item box
|
||||||
int selectedBoxY = 0;
|
int selectedBoxY = 0;
|
||||||
if (menu_size >= VISIBLE_MENU_ITEMS && app.selected_param == menu_size - 1) {
|
if (menu_size >= VISIBLE_MENU_ITEMS && app.selected_param == menu_size - 1) {
|
||||||
selectedBoxY = MENU_ITEM_HEIGHT * min(2, app.selected_param);
|
selectedBoxY = MENU_ITEM_HEIGHT * min(2, app.selected_param);
|
||||||
} else if (app.selected_param > 0) {
|
} else if (app.selected_param > 0) {
|
||||||
selectedBoxY = MENU_ITEM_HEIGHT;
|
selectedBoxY = MENU_ITEM_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
int boxX = MENU_BOX_WIDTH + 1;
|
int boxX = MENU_BOX_WIDTH + 1;
|
||||||
int boxY = selectedBoxY + 2;
|
int boxY = selectedBoxY + 2;
|
||||||
int boxWidth = MENU_BOX_WIDTH - 1;
|
int boxWidth = MENU_BOX_WIDTH - 1;
|
||||||
int boxHeight = MENU_ITEM_HEIGHT + 1;
|
int boxHeight = MENU_ITEM_HEIGHT + 1;
|
||||||
|
|
||||||
if (app.editing_param) {
|
if (app.editing_param) {
|
||||||
gravity.display.drawBox(boxX, boxY, boxWidth, boxHeight);
|
gravity.display.drawBox(boxX, boxY, boxWidth, boxHeight);
|
||||||
drawMainSelection();
|
drawMainSelection();
|
||||||
} else {
|
} else {
|
||||||
gravity.display.drawFrame(boxX, boxY, boxWidth, boxHeight);
|
gravity.display.drawFrame(boxX, boxY, boxWidth, boxHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw the visible menu items
|
// Draw the visible menu items
|
||||||
int start_index = 0;
|
int start_index = 0;
|
||||||
if (menu_size >= VISIBLE_MENU_ITEMS && app.selected_param == menu_size - 1) {
|
if (menu_size >= VISIBLE_MENU_ITEMS && app.selected_param == menu_size - 1) {
|
||||||
start_index = menu_size - VISIBLE_MENU_ITEMS;
|
start_index = menu_size - VISIBLE_MENU_ITEMS;
|
||||||
} else if (app.selected_param > 0) {
|
} else if (app.selected_param > 0) {
|
||||||
start_index = app.selected_param - 1;
|
start_index = app.selected_param - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < min(menu_size, VISIBLE_MENU_ITEMS); ++i) {
|
for (int i = 0; i < min(menu_size, VISIBLE_MENU_ITEMS); ++i) {
|
||||||
int idx = start_index + i;
|
int idx = start_index + i;
|
||||||
drawRightAlignedText(menu_items[idx].c_str(), MENU_ITEM_HEIGHT * (i + 1) - 1);
|
drawRightAlignedText(menu_items[idx].c_str(),
|
||||||
}
|
MENU_ITEM_HEIGHT * (i + 1) - 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visual indicators for main section of screen.
|
// Visual indicators for main section of screen.
|
||||||
@ -214,318 +229,325 @@ inline void hollowTick() { gravity.display.drawBox(56, 4, 4, 4); }
|
|||||||
|
|
||||||
// Display an indicator when swing percentage matches a musical note.
|
// Display an indicator when swing percentage matches a musical note.
|
||||||
void swingDivisionMark() {
|
void swingDivisionMark() {
|
||||||
auto& ch = GetSelectedChannel();
|
auto &ch = GetSelectedChannel();
|
||||||
switch (ch.getSwing()) {
|
switch (ch.getSwing()) {
|
||||||
case 58: // 1/32nd
|
case 58: // 1/32nd
|
||||||
case 66: // 1/16th
|
case 66: // 1/16th
|
||||||
case 75: // 1/8th
|
case 75: // 1/8th
|
||||||
solidTick();
|
solidTick();
|
||||||
break;
|
break;
|
||||||
case 54: // 1/32nd tripplet
|
case 54: // 1/32nd tripplet
|
||||||
case 62: // 1/16th tripplet
|
case 62: // 1/16th tripplet
|
||||||
case 71: // 1/8th tripplet
|
case 71: // 1/8th tripplet
|
||||||
hollowTick();
|
hollowTick();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Human friendly display value for save slot.
|
// Human friendly display value for save slot.
|
||||||
String displaySaveSlot(int slot) {
|
String displaySaveSlot(int slot) {
|
||||||
if (slot >= 0 && slot < StateManager::MAX_SAVE_SLOTS / 2) {
|
if (slot >= 0 && slot < StateManager::MAX_SAVE_SLOTS / 2) {
|
||||||
return String("A") + String(slot + 1);
|
return String("A") + String(slot + 1);
|
||||||
} else if (slot >= StateManager::MAX_SAVE_SLOTS / 2 && slot <= StateManager::MAX_SAVE_SLOTS) {
|
} else if (slot >= StateManager::MAX_SAVE_SLOTS / 2 &&
|
||||||
return String("B") + String(slot - (StateManager::MAX_SAVE_SLOTS / 2) + 1);
|
slot <= StateManager::MAX_SAVE_SLOTS) {
|
||||||
}
|
return String("B") + String(slot - (StateManager::MAX_SAVE_SLOTS / 2) + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main display functions
|
// Main display functions
|
||||||
|
|
||||||
void DisplayMainPage() {
|
void DisplayMainPage() {
|
||||||
gravity.display.setFontMode(1);
|
gravity.display.setFontMode(1);
|
||||||
gravity.display.setDrawColor(2);
|
gravity.display.setDrawColor(2);
|
||||||
gravity.display.setFont(TEXT_FONT);
|
gravity.display.setFont(TEXT_FONT);
|
||||||
|
|
||||||
// Display selected editable value
|
// Display selected editable value
|
||||||
String mainText;
|
String mainText;
|
||||||
String subText;
|
String subText;
|
||||||
|
|
||||||
switch (app.selected_param) {
|
switch (app.selected_param) {
|
||||||
case PARAM_MAIN_TEMPO:
|
case PARAM_MAIN_TEMPO:
|
||||||
// Serial MIDI is too unstable to display bpm in real time.
|
// Serial MIDI is too unstable to display bpm in real time.
|
||||||
if (app.selected_source == Clock::SOURCE_EXTERNAL_MIDI) {
|
if (app.selected_source == Clock::SOURCE_EXTERNAL_MIDI) {
|
||||||
mainText = STR_EXT;
|
mainText = F("EXT");
|
||||||
} else {
|
} else {
|
||||||
mainText = String(gravity.clock.Tempo());
|
mainText = String(gravity.clock.Tempo());
|
||||||
}
|
|
||||||
subText = F("BPM");
|
|
||||||
break;
|
|
||||||
case PARAM_MAIN_SOURCE:
|
|
||||||
mainText = STR_EXT;
|
|
||||||
switch (app.selected_source) {
|
|
||||||
case Clock::SOURCE_INTERNAL:
|
|
||||||
mainText = F("INT");
|
|
||||||
subText = F("CLOCK");
|
|
||||||
break;
|
|
||||||
case Clock::SOURCE_EXTERNAL_PPQN_24:
|
|
||||||
subText = STR_24_PPQN;
|
|
||||||
break;
|
|
||||||
case Clock::SOURCE_EXTERNAL_PPQN_4:
|
|
||||||
subText = STR_4_PPQN;
|
|
||||||
break;
|
|
||||||
case Clock::SOURCE_EXTERNAL_PPQN_1:
|
|
||||||
subText = STR_1_PPQN;
|
|
||||||
break;
|
|
||||||
case Clock::SOURCE_EXTERNAL_MIDI:
|
|
||||||
subText = F("MIDI");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case PARAM_MAIN_RUN:
|
|
||||||
mainText = F("RUN");
|
|
||||||
switch (app.cv_run) {
|
|
||||||
case 0:
|
|
||||||
subText = STR_NONE;
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
subText = STR_CV_1;
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
subText = STR_CV_2;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case PARAM_MAIN_RESET:
|
|
||||||
mainText = F("RST");
|
|
||||||
switch (app.cv_reset) {
|
|
||||||
case 0:
|
|
||||||
subText = STR_NONE;
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
subText = STR_CV_1;
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
subText = STR_CV_2;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case PARAM_MAIN_PULSE:
|
|
||||||
mainText = F("OUT");
|
|
||||||
switch (app.selected_pulse) {
|
|
||||||
case Clock::PULSE_NONE:
|
|
||||||
subText = F("PULSE OFF");
|
|
||||||
break;
|
|
||||||
case Clock::PULSE_PPQN_24:
|
|
||||||
subText = STR_24_PPQN;
|
|
||||||
break;
|
|
||||||
case Clock::PULSE_PPQN_4:
|
|
||||||
subText = STR_4_PPQN;
|
|
||||||
break;
|
|
||||||
case Clock::PULSE_PPQN_1:
|
|
||||||
subText = STR_1_PPQN;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case PARAM_MAIN_ENCODER_DIR:
|
|
||||||
mainText = F("DIR");
|
|
||||||
subText = app.selected_sub_param == 0 ? STR_DEFAULT : STR_REVERSED;
|
|
||||||
break;
|
|
||||||
case PARAM_MAIN_SAVE_DATA:
|
|
||||||
case PARAM_MAIN_LOAD_DATA:
|
|
||||||
if (app.selected_sub_param == StateManager::MAX_SAVE_SLOTS) {
|
|
||||||
mainText = STR_X;
|
|
||||||
subText = STR_BACK;
|
|
||||||
} else {
|
|
||||||
// Indicate currently active slot.
|
|
||||||
if (app.selected_sub_param == app.selected_save_slot) {
|
|
||||||
solidTick();
|
|
||||||
}
|
|
||||||
mainText = displaySaveSlot(app.selected_sub_param);
|
|
||||||
subText = (app.selected_param == PARAM_MAIN_SAVE_DATA)
|
|
||||||
? F("SAVE TO SLOT")
|
|
||||||
: F("LOAD FROM SLOT");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case PARAM_MAIN_FACTORY_RESET:
|
|
||||||
if (app.selected_sub_param == 0) {
|
|
||||||
mainText = F("DEL");
|
|
||||||
subText = F("FACTORY RESET");
|
|
||||||
} else {
|
|
||||||
mainText = STR_X;
|
|
||||||
subText = STR_BACK;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
subText = F("BPM");
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_SOURCE:
|
||||||
|
mainText = F("EXT");
|
||||||
|
switch (app.selected_source) {
|
||||||
|
case Clock::SOURCE_INTERNAL:
|
||||||
|
mainText = F("INT");
|
||||||
|
subText = F("CLOCK");
|
||||||
|
break;
|
||||||
|
case Clock::SOURCE_EXTERNAL_PPQN_24:
|
||||||
|
subText = F("24 PPQN");
|
||||||
|
break;
|
||||||
|
case Clock::SOURCE_EXTERNAL_PPQN_4:
|
||||||
|
subText = F("4 PPQN");
|
||||||
|
break;
|
||||||
|
case Clock::SOURCE_EXTERNAL_PPQN_2:
|
||||||
|
subText = F("2 PPQN");
|
||||||
|
break;
|
||||||
|
case Clock::SOURCE_EXTERNAL_PPQN_1:
|
||||||
|
subText = F("1 PPQN");
|
||||||
|
break;
|
||||||
|
case Clock::SOURCE_EXTERNAL_MIDI:
|
||||||
|
subText = F("MIDI");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_RUN:
|
||||||
|
mainText = F("RUN");
|
||||||
|
switch (app.cv_run) {
|
||||||
|
case 0:
|
||||||
|
subText = F("NONE");
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
subText = F("CV 1");
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
subText = F("CV 2");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_RESET:
|
||||||
|
mainText = F("RST");
|
||||||
|
switch (app.cv_reset) {
|
||||||
|
case 0:
|
||||||
|
subText = F("NONE");
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
subText = F("CV 1");
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
subText = F("CV 2");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_PULSE:
|
||||||
|
mainText = F("OUT");
|
||||||
|
switch (app.selected_pulse) {
|
||||||
|
case Clock::PULSE_NONE:
|
||||||
|
subText = F("PULSE OFF");
|
||||||
|
break;
|
||||||
|
case Clock::PULSE_PPQN_24:
|
||||||
|
subText = F("24 PPQN PULSE");
|
||||||
|
break;
|
||||||
|
case Clock::PULSE_PPQN_4:
|
||||||
|
subText = F("4 PPQN PULSE");
|
||||||
|
break;
|
||||||
|
case Clock::PULSE_PPQN_1:
|
||||||
|
subText = F("1 PPQN PULSE");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_ENCODER_DIR:
|
||||||
|
mainText = F("DIR");
|
||||||
|
subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("REVERSED");
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_ROTATE_DISP:
|
||||||
|
mainText = F("DISP");
|
||||||
|
subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("ROTATED");
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_SAVE_DATA:
|
||||||
|
case PARAM_MAIN_LOAD_DATA:
|
||||||
|
if (app.selected_sub_param == StateManager::MAX_SAVE_SLOTS) {
|
||||||
|
mainText = F("x");
|
||||||
|
subText = F("BACK TO MAIN");
|
||||||
|
} else {
|
||||||
|
// Indicate currently active slot.
|
||||||
|
if (app.selected_sub_param == app.selected_save_slot) {
|
||||||
|
solidTick();
|
||||||
|
}
|
||||||
|
mainText = displaySaveSlot(app.selected_sub_param);
|
||||||
|
subText = (app.selected_param == PARAM_MAIN_SAVE_DATA)
|
||||||
|
? F("SAVE TO SLOT")
|
||||||
|
: F("LOAD FROM SLOT");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_RESET_STATE:
|
||||||
|
if (app.selected_sub_param == 0) {
|
||||||
|
mainText = F("RST");
|
||||||
|
subText = F("RESET ALL");
|
||||||
|
} else {
|
||||||
|
mainText = F("x");
|
||||||
|
subText = F("BACK TO MAIN");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PARAM_MAIN_FACTORY_RESET:
|
||||||
|
if (app.selected_sub_param == 0) {
|
||||||
|
mainText = F("DEL");
|
||||||
|
subText = F("FACTORY RESET");
|
||||||
|
} else {
|
||||||
|
mainText = F("x");
|
||||||
|
subText = F("BACK TO MAIN");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
drawCenteredText(mainText.c_str(), MAIN_TEXT_Y, LARGE_FONT);
|
drawCenteredText(mainText.c_str(), MAIN_TEXT_Y, LARGE_FONT);
|
||||||
drawCenteredText(subText.c_str(), SUB_TEXT_Y, TEXT_FONT);
|
drawCenteredText(subText.c_str(), SUB_TEXT_Y, TEXT_FONT);
|
||||||
|
|
||||||
// Draw Main Page menu items
|
// Draw Main Page menu items
|
||||||
String menu_items[PARAM_MAIN_LAST] = {F("TEMPO"), F("SOURCE"), F("CLK RUN"), F("CLK RESET"), F("PULSE OUT"), F("ENCODER DIR"), F("SAVE"), F("LOAD"), F("ERASE")};
|
String menu_items[PARAM_MAIN_LAST] = {
|
||||||
drawMenuItems(menu_items, PARAM_MAIN_LAST);
|
F("TEMPO"), F("RUN"), F("RST"), F("SOURCE"),
|
||||||
|
F("PULSE OUT"), F("ENCODER DIR"), F("ROTATE DISP"), F("SAVE"),
|
||||||
|
F("LOAD"), F("RESET"), F("ERASE")};
|
||||||
|
drawMenuItems(menu_items, PARAM_MAIN_LAST);
|
||||||
}
|
}
|
||||||
|
|
||||||
void DisplayChannelPage() {
|
void DisplayChannelPage() {
|
||||||
auto& ch = GetSelectedChannel();
|
auto &ch = GetSelectedChannel();
|
||||||
|
|
||||||
gravity.display.setFontMode(1);
|
gravity.display.setFontMode(1);
|
||||||
gravity.display.setDrawColor(2);
|
gravity.display.setDrawColor(2);
|
||||||
|
|
||||||
// Display selected editable value
|
// Display selected editable value
|
||||||
String mainText;
|
String mainText;
|
||||||
String subText;
|
String subText;
|
||||||
|
|
||||||
// When editing a param, just show the base value. When not editing show
|
// When editing a param, just show the base value. When not editing show
|
||||||
// the value with cv mod.
|
// the value with cv mod.
|
||||||
bool withCvMod = !app.editing_param;
|
bool withCvMod = !app.editing_param;
|
||||||
int cv1 = gravity.cv1.Read();
|
|
||||||
int cv2 = gravity.cv2.Read();
|
|
||||||
|
|
||||||
switch (app.selected_param) {
|
switch (app.selected_param) {
|
||||||
case PARAM_CH_MOD: {
|
case PARAM_CH_MOD: {
|
||||||
int mod_value = withCvMod ? ch.getClockModWithMod(cv1, cv2): ch.getClockMod();
|
int mod_value = ch.getClockMod(withCvMod);
|
||||||
if (mod_value > 1) {
|
if (mod_value > 1) {
|
||||||
mainText = F("/");
|
mainText = F("/");
|
||||||
mainText += String(mod_value);
|
mainText += String(mod_value);
|
||||||
subText = F("DIVIDE");
|
subText = F("DIVIDE");
|
||||||
} else {
|
} else {
|
||||||
mainText = F("x");
|
mainText = F("x");
|
||||||
mainText += String(abs(mod_value));
|
mainText += String(abs(mod_value));
|
||||||
subText = F("MULTIPLY");
|
subText = F("MULTIPLY");
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case PARAM_CH_PROB:
|
|
||||||
mainText = String(withCvMod ? ch.getProbabilityWithMod(cv1, cv2) : ch.getProbability()) + F("%");
|
|
||||||
subText = F("HIT CHANCE");
|
|
||||||
break;
|
|
||||||
case PARAM_CH_DUTY:
|
|
||||||
mainText = String(withCvMod ? ch.getDutyCycleWithMod(cv1, cv2) : ch.getDutyCycle()) + F("%");
|
|
||||||
subText = F("PULSE WIDTH");
|
|
||||||
break;
|
|
||||||
case PARAM_CH_OFFSET:
|
|
||||||
mainText = String(withCvMod ? ch.getOffsetWithMod(cv1, cv2) : ch.getOffset()) + F("%");
|
|
||||||
subText = F("SHIFT HIT");
|
|
||||||
break;
|
|
||||||
case PARAM_CH_SWING:
|
|
||||||
ch.getSwing() == 50
|
|
||||||
? mainText = F("OFF")
|
|
||||||
: mainText = String(withCvMod ? ch.getSwingWithMod(cv1, cv2) : ch.getSwing()) + F("%");
|
|
||||||
subText = F("DOWN BEAT");
|
|
||||||
swingDivisionMark();
|
|
||||||
break;
|
|
||||||
case PARAM_CH_EUC_STEPS:
|
|
||||||
mainText = String(withCvMod ? ch.getStepsWithMod(cv1, cv2) : ch.getSteps());
|
|
||||||
subText = STR_EUC_STEPS;
|
|
||||||
break;
|
|
||||||
case PARAM_CH_EUC_HITS:
|
|
||||||
mainText = String(withCvMod ? ch.getHitsWithMod(cv1, cv2) : ch.getHits());
|
|
||||||
subText = STR_EUC_HITS;
|
|
||||||
break;
|
|
||||||
case PARAM_CH_CV1_DEST:
|
|
||||||
case PARAM_CH_CV2_DEST: {
|
|
||||||
mainText = (app.selected_param == PARAM_CH_CV1_DEST) ? F("CV1") : F("CV2");
|
|
||||||
switch ((app.selected_param == PARAM_CH_CV1_DEST) ? ch.getCv1Dest() : ch.getCv2Dest()) {
|
|
||||||
case CV_DEST_NONE:
|
|
||||||
subText = F("NONE");
|
|
||||||
break;
|
|
||||||
case CV_DEST_MOD:
|
|
||||||
subText = F("CLOCK MOD");
|
|
||||||
break;
|
|
||||||
case CV_DEST_PROB:
|
|
||||||
subText = F("PROBABILITY");
|
|
||||||
break;
|
|
||||||
case CV_DEST_DUTY:
|
|
||||||
subText = F("DUTY CYCLE");
|
|
||||||
break;
|
|
||||||
case CV_DEST_OFFSET:
|
|
||||||
subText = F("OFFSET");
|
|
||||||
break;
|
|
||||||
case CV_DEST_SWING:
|
|
||||||
subText = F("SWING");
|
|
||||||
break;
|
|
||||||
case CV_DEST_EUC_STEPS:
|
|
||||||
subText = STR_EUC_STEPS;
|
|
||||||
break;
|
|
||||||
case CV_DEST_EUC_HITS:
|
|
||||||
subText = STR_EUC_HITS;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PARAM_CH_PROB:
|
||||||
|
mainText = String(ch.getProbability(withCvMod)) + F("%");
|
||||||
|
subText = F("HIT CHANCE");
|
||||||
|
break;
|
||||||
|
case PARAM_CH_DUTY:
|
||||||
|
mainText = String(ch.getDutyCycle(withCvMod)) + F("%");
|
||||||
|
subText = F("PULSE WIDTH");
|
||||||
|
break;
|
||||||
|
case PARAM_CH_OFFSET:
|
||||||
|
mainText = String(ch.getOffset(withCvMod)) + F("%");
|
||||||
|
subText = F("SHIFT HIT");
|
||||||
|
break;
|
||||||
|
case PARAM_CH_SWING:
|
||||||
|
ch.getSwing() == 50 ? mainText = F("OFF")
|
||||||
|
: mainText = String(ch.getSwing(withCvMod)) + F("%");
|
||||||
|
subText = "DOWN BEAT";
|
||||||
|
swingDivisionMark();
|
||||||
|
break;
|
||||||
|
case PARAM_CH_CV1_DEST:
|
||||||
|
case PARAM_CH_CV2_DEST: {
|
||||||
|
mainText = (app.selected_param == PARAM_CH_CV1_DEST) ? F("CV1") : F("CV2");
|
||||||
|
switch ((app.selected_param == PARAM_CH_CV1_DEST) ? ch.getCv1Dest()
|
||||||
|
: ch.getCv2Dest()) {
|
||||||
|
case CV_DEST_NONE:
|
||||||
|
subText = F("NONE");
|
||||||
|
break;
|
||||||
|
case CV_DEST_MOD:
|
||||||
|
subText = F("CLOCK MOD");
|
||||||
|
break;
|
||||||
|
case CV_DEST_PROB:
|
||||||
|
subText = F("PROBABILITY");
|
||||||
|
break;
|
||||||
|
case CV_DEST_DUTY:
|
||||||
|
subText = F("DUTY CYCLE");
|
||||||
|
break;
|
||||||
|
case CV_DEST_OFFSET:
|
||||||
|
subText = F("OFFSET");
|
||||||
|
break;
|
||||||
|
case CV_DEST_SWING:
|
||||||
|
subText = F("SWING");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
drawCenteredText(mainText.c_str(), MAIN_TEXT_Y, LARGE_FONT);
|
drawCenteredText(mainText.c_str(), MAIN_TEXT_Y, LARGE_FONT);
|
||||||
drawCenteredText(subText.c_str(), SUB_TEXT_Y, TEXT_FONT);
|
drawCenteredText(subText.c_str(), SUB_TEXT_Y, TEXT_FONT);
|
||||||
|
|
||||||
// Draw Channel Page menu items
|
// Draw Channel Page menu items
|
||||||
String menu_items[PARAM_CH_LAST] = {
|
String menu_items[PARAM_CH_LAST] = {
|
||||||
F("MOD"), F("PROBABILITY"), F("DUTY"), F("OFFSET"), F("SWING"), F("EUCLID STEPS"),
|
F("MOD"), F("PROBABILITY"), F("DUTY"), F("OFFSET"),
|
||||||
F("EUCLID HITS"), F("CV1 MOD"), F("CV2 MOD")};
|
F("SWING"), F("CV1 MOD"), F("CV2 MOD")};
|
||||||
drawMenuItems(menu_items, PARAM_CH_LAST);
|
drawMenuItems(menu_items, PARAM_CH_LAST);
|
||||||
}
|
}
|
||||||
|
|
||||||
void DisplaySelectedChannel() {
|
void DisplaySelectedChannel() {
|
||||||
int boxX = CHANNEL_BOX_WIDTH;
|
int boxX = CHANNEL_BOX_WIDTH;
|
||||||
int boxY = CHANNEL_BOXES_Y;
|
int boxY = CHANNEL_BOXES_Y;
|
||||||
int boxWidth = CHANNEL_BOX_WIDTH;
|
int boxWidth = CHANNEL_BOX_WIDTH;
|
||||||
int boxHeight = CHANNEL_BOX_HEIGHT;
|
int boxHeight = CHANNEL_BOX_HEIGHT;
|
||||||
int textOffset = 7; // Half of font width
|
int textOffset = 7; // Half of font width
|
||||||
|
|
||||||
// Draw top and right side of frame.
|
// Draw top and right side of frame.
|
||||||
gravity.display.drawHLine(1, boxY, SCREEN_WIDTH - 2);
|
gravity.display.drawHLine(1, boxY, SCREEN_WIDTH - 2);
|
||||||
gravity.display.drawVLine(SCREEN_WIDTH - 2, boxY, boxHeight);
|
gravity.display.drawVLine(SCREEN_WIDTH - 2, boxY, boxHeight);
|
||||||
|
|
||||||
for (int i = 0; i < Gravity::OUTPUT_COUNT + 1; i++) {
|
for (int i = 0; i < Gravity::OUTPUT_COUNT + 1; i++) {
|
||||||
// Draw box frame or filled selected box.
|
// Draw box frame or filled selected box.
|
||||||
gravity.display.setDrawColor(1);
|
gravity.display.setDrawColor(1);
|
||||||
(app.selected_channel == i)
|
(app.selected_channel == i)
|
||||||
? gravity.display.drawBox(i * boxWidth, boxY, boxWidth, boxHeight)
|
? gravity.display.drawBox(i * boxWidth, boxY, boxWidth, boxHeight)
|
||||||
: gravity.display.drawVLine(i * boxWidth, boxY, boxHeight);
|
: gravity.display.drawVLine(i * boxWidth, boxY, boxHeight);
|
||||||
|
|
||||||
// Draw clock status icon or each channel number.
|
// Draw clock status icon or each channel number.
|
||||||
gravity.display.setDrawColor(2);
|
gravity.display.setDrawColor(2);
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
gravity.display.setBitmapMode(1);
|
gravity.display.setBitmapMode(1);
|
||||||
auto icon = gravity.clock.IsPaused() ? pause_icon : play_icon;
|
auto icon = gravity.clock.IsPaused() ? pause_icon : play_icon;
|
||||||
gravity.display.drawXBMP(2, boxY, play_icon_width, play_icon_height, icon);
|
gravity.display.drawXBMP(2, boxY, play_icon_width, play_icon_height,
|
||||||
} else {
|
icon);
|
||||||
gravity.display.setFont(TEXT_FONT);
|
} else {
|
||||||
gravity.display.setCursor((i * boxWidth) + textOffset, SCREEN_HEIGHT - 3);
|
gravity.display.setFont(TEXT_FONT);
|
||||||
gravity.display.print(i);
|
gravity.display.setCursor((i * boxWidth) + textOffset, SCREEN_HEIGHT - 3);
|
||||||
}
|
gravity.display.print(i);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void UpdateDisplay() {
|
void UpdateDisplay() {
|
||||||
app.refresh_screen = false;
|
app.refresh_screen = false;
|
||||||
gravity.display.firstPage();
|
gravity.display.firstPage();
|
||||||
do {
|
do {
|
||||||
if (app.selected_channel == 0) {
|
if (app.selected_channel == 0) {
|
||||||
DisplayMainPage();
|
DisplayMainPage();
|
||||||
} else {
|
} else {
|
||||||
DisplayChannelPage();
|
DisplayChannelPage();
|
||||||
}
|
}
|
||||||
// Global channel select UI.
|
// Global channel select UI.
|
||||||
DisplaySelectedChannel();
|
DisplaySelectedChannel();
|
||||||
} while (gravity.display.nextPage());
|
} while (gravity.display.nextPage());
|
||||||
}
|
}
|
||||||
|
|
||||||
void Bootsplash() {
|
void Bootsplash() {
|
||||||
gravity.display.firstPage();
|
gravity.display.firstPage();
|
||||||
do {
|
do {
|
||||||
int textWidth;
|
int textWidth;
|
||||||
String loadingText = F("LOADING....");
|
String loadingText = F("LOADING....");
|
||||||
gravity.display.setFont(TEXT_FONT);
|
gravity.display.setFont(TEXT_FONT);
|
||||||
|
|
||||||
textWidth = gravity.display.getStrWidth(StateManager::SKETCH_NAME);
|
textWidth = gravity.display.getStrWidth(StateManager::SKETCH_NAME);
|
||||||
gravity.display.drawStr(16 + (textWidth / 2), 20, StateManager::SKETCH_NAME);
|
gravity.display.drawStr(16 + (textWidth / 2), 20,
|
||||||
|
StateManager::SKETCH_NAME);
|
||||||
|
|
||||||
textWidth = gravity.display.getStrWidth(StateManager::SEMANTIC_VERSION);
|
textWidth = gravity.display.getStrWidth(StateManager::SEMANTIC_VERSION);
|
||||||
gravity.display.drawStr(16 + (textWidth / 2), 32, StateManager::SEMANTIC_VERSION);
|
gravity.display.drawStr(16 + (textWidth / 2), 32,
|
||||||
|
StateManager::SEMANTIC_VERSION);
|
||||||
|
|
||||||
textWidth = gravity.display.getStrWidth(loadingText.c_str());
|
textWidth = gravity.display.getStrWidth(loadingText.c_str());
|
||||||
gravity.display.drawStr(26 + (textWidth / 2), 44, loadingText.c_str());
|
gravity.display.drawStr(26 + (textWidth / 2), 44, loadingText.c_str());
|
||||||
} while (gravity.display.nextPage());
|
} while (gravity.display.nextPage());
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif // DISPLAY_H
|
#endif // DISPLAY_H
|
||||||
|
|||||||
@ -17,7 +17,9 @@
|
|||||||
|
|
||||||
// Define the constants for the current firmware.
|
// Define the constants for the current firmware.
|
||||||
const char StateManager::SKETCH_NAME[] = "ALT GRAVITY";
|
const char StateManager::SKETCH_NAME[] = "ALT GRAVITY";
|
||||||
const char StateManager::SEMANTIC_VERSION[] = "V2.0.0BETA4"; // NOTE: This should match the version in the library.properties file.
|
const char StateManager::SEMANTIC_VERSION[] =
|
||||||
|
"V2.0.1"; // NOTE: This should match the version in the
|
||||||
|
// library.properties file.
|
||||||
|
|
||||||
// Number of available save slots.
|
// Number of available save slots.
|
||||||
const byte StateManager::MAX_SAVE_SLOTS = 10;
|
const byte StateManager::MAX_SAVE_SLOTS = 10;
|
||||||
@ -32,196 +34,206 @@ const int StateManager::EEPROM_DATA_START_ADDR = sizeof(StateManager::Metadata);
|
|||||||
|
|
||||||
StateManager::StateManager() : _isDirty(false), _lastChangeTime(0) {}
|
StateManager::StateManager() : _isDirty(false), _lastChangeTime(0) {}
|
||||||
|
|
||||||
bool StateManager::initialize(AppState& app) {
|
bool StateManager::initialize(AppState &app) {
|
||||||
if (_isDataValid()) {
|
noInterrupts();
|
||||||
// Load global settings.
|
bool success = false;
|
||||||
_loadMetadata(app);
|
if (_isDataValid()) {
|
||||||
// Load app data from the transient slot.
|
// Load global settings.
|
||||||
_loadState(app, TRANSIENT_SLOT);
|
_loadMetadata(app);
|
||||||
return true;
|
// Load app data from the transient slot.
|
||||||
}
|
_loadState(app, TRANSIENT_SLOT);
|
||||||
// EEPROM does not contain save data for this firmware & version.
|
success = true;
|
||||||
else {
|
}
|
||||||
// Erase EEPROM and initialize state. Save default pattern to all save slots.
|
// EEPROM does not contain save data for this firmware & version.
|
||||||
factoryReset(app);
|
else {
|
||||||
return false;
|
// Erase EEPROM and initialize state. Save default pattern to all save
|
||||||
}
|
// slots.
|
||||||
|
factoryReset(app);
|
||||||
|
}
|
||||||
|
interrupts();
|
||||||
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool StateManager::loadData(AppState& app, byte slot_index) {
|
bool StateManager::loadData(AppState &app, byte slot_index) {
|
||||||
// Check if slot_index is within max range + 1 for transient.
|
// Check if slot_index is within max range + 1 for transient.
|
||||||
if (slot_index >= MAX_SAVE_SLOTS + 1) return false;
|
if (slot_index >= MAX_SAVE_SLOTS + 1)
|
||||||
|
return false;
|
||||||
|
|
||||||
// Load the state data from the specified EEPROM slot and update the app state save slot.
|
noInterrupts();
|
||||||
_loadState(app, slot_index);
|
|
||||||
app.selected_save_slot = slot_index;
|
|
||||||
// Persist this change in the global metadata.
|
|
||||||
_saveMetadata(app);
|
|
||||||
|
|
||||||
return true;
|
// Load the state data from the specified EEPROM slot and update the app state
|
||||||
|
// save slot.
|
||||||
|
_loadState(app, slot_index);
|
||||||
|
app.selected_save_slot = slot_index;
|
||||||
|
// Persist this change in the global metadata on next update.
|
||||||
|
_isDirty = true;
|
||||||
|
|
||||||
|
interrupts();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save app state to user specified save slot.
|
// Save app state to user specified save slot.
|
||||||
void StateManager::saveData(const AppState& app) {
|
void StateManager::saveData(const AppState &app) {
|
||||||
// Check if slot_index is within max range + 1 for transient.
|
noInterrupts();
|
||||||
if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) return;
|
// Check if slot_index is within max range + 1 for transient.
|
||||||
|
if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) {
|
||||||
|
interrupts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_saveState(app, app.selected_save_slot);
|
_saveState(app, app.selected_save_slot);
|
||||||
|
_saveMetadata(app);
|
||||||
|
_isDirty = false;
|
||||||
|
interrupts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save transient state if it has changed and enough time has passed since last
|
||||||
|
// save.
|
||||||
|
void StateManager::update(const AppState &app) {
|
||||||
|
if (_isDirty && (millis() - _lastChangeTime > SAVE_DELAY_MS)) {
|
||||||
|
noInterrupts();
|
||||||
|
_saveState(app, TRANSIENT_SLOT);
|
||||||
_saveMetadata(app);
|
_saveMetadata(app);
|
||||||
_isDirty = false;
|
_isDirty = false;
|
||||||
|
interrupts();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save transient state if it has changed and enough time has passed since last save.
|
void StateManager::reset(AppState &app) {
|
||||||
void StateManager::update(const AppState& app) {
|
noInterrupts();
|
||||||
if (_isDirty && (millis() - _lastChangeTime > SAVE_DELAY_MS)) {
|
|
||||||
_saveState(app, TRANSIENT_SLOT);
|
|
||||||
_saveMetadata(app);
|
|
||||||
_isDirty = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void StateManager::reset(AppState& app) {
|
AppState default_app;
|
||||||
AppState default_app;
|
app.tempo = default_app.tempo;
|
||||||
app.tempo = default_app.tempo;
|
app.selected_param = default_app.selected_param;
|
||||||
app.selected_param = default_app.selected_param;
|
app.selected_channel = default_app.selected_channel;
|
||||||
app.selected_channel = default_app.selected_channel;
|
app.selected_source = default_app.selected_source;
|
||||||
app.selected_source = default_app.selected_source;
|
app.selected_pulse = default_app.selected_pulse;
|
||||||
app.selected_pulse = default_app.selected_pulse;
|
app.cv_run = default_app.cv_run;
|
||||||
app.cv_run = default_app.cv_run;
|
app.cv_reset = default_app.cv_reset;
|
||||||
app.cv_reset = default_app.cv_reset;
|
|
||||||
|
|
||||||
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
||||||
app.channel[i].Init();
|
app.channel[i].Init();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load global settings from Metadata
|
// Load global settings from Metadata
|
||||||
_loadMetadata(app);
|
_loadMetadata(app);
|
||||||
|
|
||||||
_isDirty = false;
|
_isDirty = false;
|
||||||
|
interrupts();
|
||||||
}
|
}
|
||||||
|
|
||||||
void StateManager::markDirty() {
|
void StateManager::markDirty() {
|
||||||
_isDirty = true;
|
_isDirty = true;
|
||||||
_lastChangeTime = millis();
|
_lastChangeTime = millis();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erases all data in the EEPROM by writing 0 to every address.
|
// Erases all data in the EEPROM by writing 0 to every address.
|
||||||
void StateManager::factoryReset(AppState& app) {
|
void StateManager::factoryReset(AppState &app) {
|
||||||
noInterrupts();
|
noInterrupts();
|
||||||
for (unsigned int i = 0; i < EEPROM.length(); i++) {
|
for (unsigned int i = 0; i < EEPROM.length(); i++) {
|
||||||
EEPROM.write(i, 0);
|
EEPROM.write(i, 0);
|
||||||
}
|
}
|
||||||
// Initialize eeprom and save default patter to all save slots.
|
// Initialize eeprom and save default patter to all save slots.
|
||||||
_saveMetadata(app);
|
_saveMetadata(app);
|
||||||
reset(app);
|
reset(app);
|
||||||
for (int i = 0; i < MAX_SAVE_SLOTS; i++) {
|
for (int i = 0; i < MAX_SAVE_SLOTS; i++) {
|
||||||
app.selected_save_slot = i;
|
app.selected_save_slot = i;
|
||||||
_saveState(app, i);
|
_saveState(app, i);
|
||||||
}
|
}
|
||||||
_saveState(app, TRANSIENT_SLOT);
|
_saveState(app, TRANSIENT_SLOT);
|
||||||
interrupts();
|
interrupts();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool StateManager::_isDataValid() {
|
bool StateManager::_isDataValid() {
|
||||||
Metadata metadata;
|
Metadata metadata;
|
||||||
EEPROM.get(METADATA_START_ADDR, metadata);
|
EEPROM.get(METADATA_START_ADDR, metadata);
|
||||||
bool name_match = (strcmp(metadata.sketch_name, SKETCH_NAME) == 0);
|
bool name_match = (strcmp(metadata.sketch_name, SKETCH_NAME) == 0);
|
||||||
bool version_match = (strcmp(metadata.version, SEMANTIC_VERSION) == 0);
|
bool version_match = (strcmp(metadata.version, SEMANTIC_VERSION) == 0);
|
||||||
return name_match && version_match;
|
return name_match && version_match;
|
||||||
}
|
}
|
||||||
|
|
||||||
void StateManager::_saveState(const AppState& app, byte slot_index) {
|
void StateManager::_saveState(const AppState &app, byte slot_index) {
|
||||||
// Check if slot_index is within max range + 1 for transient.
|
// Check if slot_index is within max range + 1 for transient.
|
||||||
if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) return;
|
if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1)
|
||||||
|
return;
|
||||||
|
|
||||||
noInterrupts();
|
static EepromData save_data;
|
||||||
static EepromData save_data;
|
|
||||||
|
|
||||||
save_data.tempo = app.tempo;
|
save_data.tempo = app.tempo;
|
||||||
save_data.selected_param = app.selected_param;
|
save_data.selected_param = app.selected_param;
|
||||||
save_data.selected_channel = app.selected_channel;
|
save_data.selected_channel = app.selected_channel;
|
||||||
save_data.selected_source = static_cast<byte>(app.selected_source);
|
save_data.selected_source = static_cast<byte>(app.selected_source);
|
||||||
save_data.selected_pulse = static_cast<byte>(app.selected_pulse);
|
save_data.selected_pulse = static_cast<byte>(app.selected_pulse);
|
||||||
save_data.cv_run = app.cv_run;
|
save_data.cv_run = app.cv_run;
|
||||||
save_data.cv_reset = app.cv_reset;
|
save_data.cv_reset = app.cv_reset;
|
||||||
|
|
||||||
// TODO: break this out into a separate function. Save State should be
|
// TODO: break this out into a separate function. Save State should be
|
||||||
// broken out into global / per-channel save methods. When saving via
|
// broken out into global / per-channel save methods. When saving via
|
||||||
// "update" only save state for the current channel since other channels
|
// "update" only save state for the current channel since other channels
|
||||||
// will not have changed when saving user edits.
|
// will not have changed when saving user edits.
|
||||||
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
||||||
const auto& ch = app.channel[i];
|
const auto &ch = app.channel[i];
|
||||||
auto& save_ch = save_data.channel_data[i];
|
auto &save_ch = save_data.channel_data[i];
|
||||||
save_ch.base_clock_mod_index = ch.getClockModIndex();
|
save_ch.base_clock_mod_index = ch.getClockModIndex(false);
|
||||||
save_ch.base_probability = ch.getProbability();
|
save_ch.base_probability = ch.getProbability(false);
|
||||||
save_ch.base_duty_cycle = ch.getDutyCycle();
|
save_ch.base_duty_cycle = ch.getDutyCycle(false);
|
||||||
save_ch.base_offset = ch.getOffset();
|
save_ch.base_offset = ch.getOffset(false);
|
||||||
save_ch.base_swing = ch.getSwing();
|
save_ch.cv1_dest = static_cast<byte>(ch.getCv1Dest());
|
||||||
save_ch.base_euc_steps = ch.getSteps();
|
save_ch.cv2_dest = static_cast<byte>(ch.getCv2Dest());
|
||||||
save_ch.base_euc_hits = ch.getHits();
|
}
|
||||||
save_ch.cv1_dest = static_cast<byte>(ch.getCv1Dest());
|
|
||||||
save_ch.cv2_dest = static_cast<byte>(ch.getCv2Dest());
|
|
||||||
}
|
|
||||||
|
|
||||||
int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData));
|
int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData));
|
||||||
EEPROM.put(address, save_data);
|
EEPROM.put(address, save_data);
|
||||||
interrupts();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void StateManager::_loadState(AppState& app, byte slot_index) {
|
void StateManager::_loadState(AppState &app, byte slot_index) {
|
||||||
// Check if slot_index is within max range + 1 for transient.
|
// Check if slot_index is within max range + 1 for transient.
|
||||||
if (slot_index >= MAX_SAVE_SLOTS + 1) return;
|
if (slot_index >= MAX_SAVE_SLOTS + 1)
|
||||||
|
return;
|
||||||
|
|
||||||
noInterrupts();
|
static EepromData load_data;
|
||||||
static EepromData load_data;
|
int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData));
|
||||||
int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData));
|
EEPROM.get(address, load_data);
|
||||||
EEPROM.get(address, load_data);
|
|
||||||
|
|
||||||
// Restore app state from loaded data.
|
// Restore app state from loaded data.
|
||||||
app.tempo = load_data.tempo;
|
app.tempo = load_data.tempo;
|
||||||
app.selected_param = load_data.selected_param;
|
app.selected_param = load_data.selected_param;
|
||||||
app.selected_channel = load_data.selected_channel;
|
app.selected_channel = load_data.selected_channel;
|
||||||
app.selected_source = static_cast<Clock::Source>(load_data.selected_source);
|
app.selected_source = static_cast<Clock::Source>(load_data.selected_source);
|
||||||
app.selected_pulse = static_cast<Clock::Pulse>(load_data.selected_pulse);
|
app.selected_pulse = static_cast<Clock::Pulse>(load_data.selected_pulse);
|
||||||
app.cv_run = load_data.cv_run;
|
app.cv_run = load_data.cv_run;
|
||||||
app.cv_reset = load_data.cv_reset;
|
app.cv_reset = load_data.cv_reset;
|
||||||
|
|
||||||
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
|
||||||
auto& ch = app.channel[i];
|
auto &ch = app.channel[i];
|
||||||
const auto& saved_ch_state = load_data.channel_data[i];
|
const auto &saved_ch_state = load_data.channel_data[i];
|
||||||
|
|
||||||
ch.setClockMod(saved_ch_state.base_clock_mod_index);
|
ch.setClockMod(saved_ch_state.base_clock_mod_index);
|
||||||
ch.setProbability(saved_ch_state.base_probability);
|
ch.setProbability(saved_ch_state.base_probability);
|
||||||
ch.setDutyCycle(saved_ch_state.base_duty_cycle);
|
ch.setDutyCycle(saved_ch_state.base_duty_cycle);
|
||||||
ch.setOffset(saved_ch_state.base_offset);
|
ch.setOffset(saved_ch_state.base_offset);
|
||||||
ch.setSwing(saved_ch_state.base_swing);
|
ch.setCv1Dest(static_cast<CvDestination>(saved_ch_state.cv1_dest));
|
||||||
ch.setSteps(saved_ch_state.base_euc_steps);
|
ch.setCv2Dest(static_cast<CvDestination>(saved_ch_state.cv2_dest));
|
||||||
ch.setHits(saved_ch_state.base_euc_hits);
|
}
|
||||||
ch.setCv1Dest(static_cast<CvDestination>(saved_ch_state.cv1_dest));
|
|
||||||
ch.setCv2Dest(static_cast<CvDestination>(saved_ch_state.cv2_dest));
|
|
||||||
}
|
|
||||||
interrupts();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void StateManager::_saveMetadata(const AppState& app) {
|
void StateManager::_saveMetadata(const AppState &app) {
|
||||||
noInterrupts();
|
Metadata current_meta;
|
||||||
Metadata current_meta;
|
strcpy(current_meta.sketch_name, SKETCH_NAME);
|
||||||
strcpy(current_meta.sketch_name, SKETCH_NAME);
|
strcpy(current_meta.version, SEMANTIC_VERSION);
|
||||||
strcpy(current_meta.version, SEMANTIC_VERSION);
|
|
||||||
|
|
||||||
// Global user settings
|
// Global user settings
|
||||||
current_meta.selected_save_slot = app.selected_save_slot;
|
current_meta.selected_save_slot = app.selected_save_slot;
|
||||||
current_meta.encoder_reversed = app.encoder_reversed;
|
current_meta.encoder_reversed = app.encoder_reversed;
|
||||||
|
current_meta.rotate_display = app.rotate_display;
|
||||||
|
|
||||||
EEPROM.put(METADATA_START_ADDR, current_meta);
|
EEPROM.put(METADATA_START_ADDR, current_meta);
|
||||||
interrupts();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void StateManager::_loadMetadata(AppState& app) {
|
void StateManager::_loadMetadata(AppState &app) {
|
||||||
noInterrupts();
|
Metadata metadata;
|
||||||
Metadata metadata;
|
EEPROM.get(METADATA_START_ADDR, metadata);
|
||||||
EEPROM.get(METADATA_START_ADDR, metadata);
|
app.selected_save_slot = metadata.selected_save_slot;
|
||||||
app.selected_save_slot = metadata.selected_save_slot;
|
app.encoder_reversed = metadata.encoder_reversed;
|
||||||
app.encoder_reversed = metadata.encoder_reversed;
|
app.rotate_display = metadata.rotate_display;
|
||||||
interrupts();
|
|
||||||
}
|
}
|
||||||
@ -19,81 +19,79 @@
|
|||||||
struct AppState;
|
struct AppState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Manages saving and loading of the application state to and from EEPROM.
|
* @brief Manages saving and loading of the application state to and from
|
||||||
* The number of user slots is defined by MAX_SAVE_SLOTS, and one additional slot
|
* EEPROM. The number of user slots is defined by MAX_SAVE_SLOTS, and one
|
||||||
* is reseved for transient state to persist state between power cycles before
|
* additional slot is reseved for transient state to persist state between power
|
||||||
* state is explicitly saved to a user slot. Metadata is stored in the beginning
|
* cycles before state is explicitly saved to a user slot. Metadata is stored in
|
||||||
* of the memory space which stores firmware version information to validate that
|
* the beginning of the memory space which stores firmware version information
|
||||||
* the data can be loaded into the current version of AppState.
|
* to validate that the data can be loaded into the current version of AppState.
|
||||||
*/
|
*/
|
||||||
class StateManager {
|
class StateManager {
|
||||||
public:
|
public:
|
||||||
static const char SKETCH_NAME[];
|
static const char SKETCH_NAME[];
|
||||||
static const char SEMANTIC_VERSION[];
|
static const char SEMANTIC_VERSION[];
|
||||||
static const byte MAX_SAVE_SLOTS;
|
static const byte MAX_SAVE_SLOTS;
|
||||||
static const byte TRANSIENT_SLOT;
|
static const byte TRANSIENT_SLOT;
|
||||||
|
|
||||||
StateManager();
|
StateManager();
|
||||||
|
|
||||||
// Populate the AppState instance with values from EEPROM if they exist.
|
// Populate the AppState instance with values from EEPROM if they exist.
|
||||||
bool initialize(AppState& app);
|
bool initialize(AppState &app);
|
||||||
// Load data from specified slot.
|
// Load data from specified slot.
|
||||||
bool loadData(AppState& app, byte slot_index);
|
bool loadData(AppState &app, byte slot_index);
|
||||||
// Save data to specified slot.
|
// Save data to specified slot.
|
||||||
void saveData(const AppState& app);
|
void saveData(const AppState &app);
|
||||||
// Reset AppState instance back to default values.
|
// Reset AppState instance back to default values.
|
||||||
void reset(AppState& app);
|
void reset(AppState &app);
|
||||||
// Call from main loop, check if state has changed and needs to be saved.
|
// Call from main loop, check if state has changed and needs to be saved.
|
||||||
void update(const AppState& app);
|
void update(const AppState &app);
|
||||||
// Indicate that state has changed and we should save.
|
// Indicate that state has changed and we should save.
|
||||||
void markDirty();
|
void markDirty();
|
||||||
// Erase all data stored in the EEPROM.
|
// Erase all data stored in the EEPROM.
|
||||||
void factoryReset(AppState& app);
|
void factoryReset(AppState &app);
|
||||||
|
|
||||||
// This struct holds the data that identifies the firmware version.
|
// This struct holds the data that identifies the firmware version.
|
||||||
struct Metadata {
|
struct Metadata {
|
||||||
char sketch_name[16];
|
char sketch_name[16];
|
||||||
char version[16];
|
char version[16];
|
||||||
// Additional global/hardware settings
|
// Additional global/hardware settings
|
||||||
byte selected_save_slot;
|
byte selected_save_slot;
|
||||||
bool encoder_reversed;
|
bool encoder_reversed;
|
||||||
};
|
bool rotate_display;
|
||||||
struct ChannelState {
|
};
|
||||||
byte base_clock_mod_index;
|
struct ChannelState {
|
||||||
byte base_probability;
|
byte base_clock_mod_index;
|
||||||
byte base_duty_cycle;
|
byte base_probability;
|
||||||
byte base_offset;
|
byte base_duty_cycle;
|
||||||
byte base_swing;
|
byte base_offset;
|
||||||
byte base_euc_steps;
|
byte cv1_dest; // Cast the CvDestination enum as a byte for storage
|
||||||
byte base_euc_hits;
|
byte cv2_dest; // Cast the CvDestination enum as a byte for storage
|
||||||
byte cv1_dest; // Cast the CvDestination enum as a byte for storage
|
};
|
||||||
byte cv2_dest; // Cast the CvDestination enum as a byte for storage
|
// This struct holds all the parameters we want to save.
|
||||||
};
|
struct EepromData {
|
||||||
// This struct holds all the parameters we want to save.
|
int tempo;
|
||||||
struct EepromData {
|
byte selected_param;
|
||||||
int tempo;
|
byte selected_channel;
|
||||||
byte selected_param;
|
byte selected_source;
|
||||||
byte selected_channel;
|
byte selected_pulse;
|
||||||
byte selected_source;
|
byte cv_run;
|
||||||
byte selected_pulse;
|
byte cv_reset;
|
||||||
byte cv_run;
|
ChannelState channel_data[Gravity::OUTPUT_COUNT];
|
||||||
byte cv_reset;
|
};
|
||||||
ChannelState channel_data[Gravity::OUTPUT_COUNT];
|
|
||||||
};
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool _isDataValid();
|
bool _isDataValid();
|
||||||
void _saveMetadata(const AppState& app);
|
void _saveMetadata(const AppState &app);
|
||||||
void _loadMetadata(AppState& app);
|
void _loadMetadata(AppState &app);
|
||||||
void _saveState(const AppState& app, byte slot_index);
|
void _saveState(const AppState &app, byte slot_index);
|
||||||
void _loadState(AppState& app, byte slot_index);
|
void _loadState(AppState &app, byte slot_index);
|
||||||
|
|
||||||
static const unsigned long SAVE_DELAY_MS;
|
static const unsigned long SAVE_DELAY_MS;
|
||||||
static const int METADATA_START_ADDR;
|
static const int METADATA_START_ADDR;
|
||||||
static const int EEPROM_DATA_START_ADDR;
|
static const int EEPROM_DATA_START_ADDR;
|
||||||
|
|
||||||
bool _isDirty;
|
bool _isDirty;
|
||||||
unsigned long _lastChangeTime;
|
unsigned long _lastChangeTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // SAVE_STATE_H
|
#endif // SAVE_STATE_H
|
||||||
371
firmware/Rhythm/Rhythm.ino
Normal file
371
firmware/Rhythm/Rhythm.ino
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
#include "maps.h"
|
||||||
|
#include <EEPROM.h>
|
||||||
|
#include <Wire.h>
|
||||||
|
#include <libGravity.h>
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const int MAX_DENSITY = 255;
|
||||||
|
const int MAP_RESOLUTION = 255;
|
||||||
|
const int MAX_CHAOS = 255;
|
||||||
|
|
||||||
|
// EEPROM addrs
|
||||||
|
const int EEPROM_INIT_ADDR = 0;
|
||||||
|
const int EEPROM_DENS_K = 1;
|
||||||
|
const int EEPROM_DENS_S = 3;
|
||||||
|
const int EEPROM_DENS_H = 5;
|
||||||
|
const int EEPROM_MAP_X = 7;
|
||||||
|
const int EEPROM_MAP_Y = 9;
|
||||||
|
const int EEPROM_CHAOS = 11;
|
||||||
|
const byte EEPROM_INIT_FLAG = 0xAC; // 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;
|
||||||
|
|
||||||
|
// UI & Navigation
|
||||||
|
enum SelectedParam {
|
||||||
|
PARAM_KICK_DENS = 0,
|
||||||
|
PARAM_SNARE_DENS = 1,
|
||||||
|
PARAM_HIHAT_DENS = 2,
|
||||||
|
PARAM_CHAOS = 3,
|
||||||
|
PARAM_MAP_X = 4,
|
||||||
|
PARAM_MAP_Y = 5,
|
||||||
|
PARAM_LAST = 6
|
||||||
|
};
|
||||||
|
|
||||||
|
SelectedParam current_param = PARAM_KICK_DENS;
|
||||||
|
bool editing_param = false;
|
||||||
|
bool needs_redraw = true;
|
||||||
|
unsigned long last_redraw = 0;
|
||||||
|
const unsigned long REDRAW_DELAY_MS = 30; // ~33fps limit
|
||||||
|
|
||||||
|
// Sequencer State
|
||||||
|
int current_step = 0;
|
||||||
|
bool is_playing = false;
|
||||||
|
|
||||||
|
// Engine Parameters (0-255)
|
||||||
|
int inst_density[3] = {128, 128,
|
||||||
|
128}; // Default 50% density for kick, snare, hihat
|
||||||
|
int map_x = 0; // 0 to 255 (0 = House, 127 = Breakbeat, 255 = Hiphop)
|
||||||
|
int map_y = 127; // 0 to 255 (0 = Sparse, 127 = Standard, 255 = Busy)
|
||||||
|
int chaos_amount = 0; // 0 to 255
|
||||||
|
|
||||||
|
volatile int cv1_val = 0;
|
||||||
|
volatile int cv2_val = 0;
|
||||||
|
|
||||||
|
// LFSR State for Chaos
|
||||||
|
uint16_t lfsr = 0xACE1;
|
||||||
|
|
||||||
|
// Math Helper: 1D Linear Interpolation between two bytes
|
||||||
|
uint8_t lerp(uint8_t a, uint8_t b, uint8_t t) {
|
||||||
|
// t is 0-255. returns a if t=0, b if t=255
|
||||||
|
return a + (((b - a) * t) >> 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Math Helper: Get threshold from 2D map via interpolation
|
||||||
|
uint8_t GetThreshold(int inst, int step, int x_pos, int y_pos) {
|
||||||
|
// x_pos is 0-255 mapped across 4 nodes (0, 1, 2, 3). Distance is 85 (255 / 3)
|
||||||
|
// y_pos is 0-255 mapped across 4 nodes (0, 1, 2, 3). Distance is 85 (255 / 3)
|
||||||
|
|
||||||
|
int x_idx = x_pos / 85;
|
||||||
|
int y_idx = y_pos / 85;
|
||||||
|
|
||||||
|
uint8_t x_frac = (x_pos % 85) * 3; // scale remainder 0-84 up to 0-255
|
||||||
|
uint8_t y_frac = (y_pos % 85) * 3;
|
||||||
|
|
||||||
|
// Guard against out of bounds if exactly 255
|
||||||
|
if (x_idx >= 3) {
|
||||||
|
x_idx = 2;
|
||||||
|
x_frac = 255;
|
||||||
|
}
|
||||||
|
if (y_idx >= 3) {
|
||||||
|
y_idx = 2;
|
||||||
|
y_frac = 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read 4 corners from PROGMEM
|
||||||
|
uint8_t p00 = pgm_read_byte(&PATTERN_MAPS[x_idx][y_idx][inst][step]);
|
||||||
|
uint8_t p10 = pgm_read_byte(&PATTERN_MAPS[x_idx + 1][y_idx][inst][step]);
|
||||||
|
uint8_t p01 = pgm_read_byte(&PATTERN_MAPS[x_idx][y_idx + 1][inst][step]);
|
||||||
|
uint8_t p11 = pgm_read_byte(&PATTERN_MAPS[x_idx + 1][y_idx + 1][inst][step]);
|
||||||
|
|
||||||
|
// Bilinear interpolation
|
||||||
|
uint8_t lerp_top = lerp(p00, p10, x_frac);
|
||||||
|
uint8_t lerp_bottom = lerp(p01, p11, x_frac);
|
||||||
|
return lerp(lerp_top, lerp_bottom, y_frac);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LoadState() {
|
||||||
|
if (EEPROM.read(EEPROM_INIT_ADDR) == EEPROM_INIT_FLAG) {
|
||||||
|
EEPROM.get(EEPROM_DENS_K, inst_density[0]);
|
||||||
|
EEPROM.get(EEPROM_DENS_S, inst_density[1]);
|
||||||
|
EEPROM.get(EEPROM_DENS_H, inst_density[2]);
|
||||||
|
EEPROM.get(EEPROM_MAP_X, map_x);
|
||||||
|
EEPROM.get(EEPROM_MAP_Y, map_y);
|
||||||
|
EEPROM.get(EEPROM_CHAOS, chaos_amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SaveState() {
|
||||||
|
EEPROM.update(EEPROM_INIT_ADDR, EEPROM_INIT_FLAG);
|
||||||
|
EEPROM.put(EEPROM_DENS_K, inst_density[0]);
|
||||||
|
EEPROM.put(EEPROM_DENS_S, inst_density[1]);
|
||||||
|
EEPROM.put(EEPROM_DENS_H, inst_density[2]);
|
||||||
|
EEPROM.put(EEPROM_MAP_X, map_x);
|
||||||
|
EEPROM.put(EEPROM_MAP_Y, map_y);
|
||||||
|
EEPROM.put(EEPROM_CHAOS, chaos_amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LFSR random bit generator (returns 0 or 1, fast)
|
||||||
|
uint8_t GetRandomBit() {
|
||||||
|
uint8_t bit = ((lfsr >> 0) ^ (lfsr >> 2) ^ (lfsr >> 3) ^ (lfsr >> 5)) & 1;
|
||||||
|
lfsr = (lfsr >> 1) | (bit << 15);
|
||||||
|
return bit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 8-bit pseudo-random number
|
||||||
|
uint8_t GetRandomByte() {
|
||||||
|
uint8_t r = 0;
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
r = (r << 1) | GetRandomBit();
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProcessSequencerTick(uint32_t tick) {
|
||||||
|
// Assuming 96 PPQN clock. We want 16th notes.
|
||||||
|
// 96 pulses per quarter note / 4 = 24 pulses per 16th note.
|
||||||
|
const int PULSES_PER_16TH = 24;
|
||||||
|
|
||||||
|
// Pulse logic outputs low halfway through the 16th note (12 pulses)
|
||||||
|
if (tick % PULSES_PER_16TH == 12) {
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
gravity.outputs[i].Low();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle new 16th note step
|
||||||
|
if (tick % PULSES_PER_16TH == 0) {
|
||||||
|
|
||||||
|
int mod_map_x = constrain(map_x + (cv1_val / 2), 0, 255);
|
||||||
|
int active_chaos = constrain(chaos_amount + (cv2_val / 2), 0, 255);
|
||||||
|
|
||||||
|
// Evaluate hits for Kick, Snare, HiHats
|
||||||
|
for (int inst = 0; inst < 3; inst++) {
|
||||||
|
uint8_t threshold = GetThreshold(inst, current_step, mod_map_x, map_y);
|
||||||
|
int active_density = inst_density[inst];
|
||||||
|
|
||||||
|
// Inject chaos
|
||||||
|
if (active_chaos > 0) {
|
||||||
|
// Chaos randomly adds or subtracts from density.
|
||||||
|
int r = GetRandomByte();
|
||||||
|
int chaos_variance = map(active_chaos, 0, 255, 0, 128);
|
||||||
|
if (GetRandomBit()) {
|
||||||
|
active_density += map(r, 0, 255, 0, chaos_variance);
|
||||||
|
} else {
|
||||||
|
active_density -= map(r, 0, 255, 0, chaos_variance);
|
||||||
|
}
|
||||||
|
active_density = constrain(active_density, 0, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire Trigger?
|
||||||
|
if (active_density > threshold) {
|
||||||
|
// Output 1-3
|
||||||
|
gravity.outputs[inst].High();
|
||||||
|
|
||||||
|
// Fire Accent Trigger? (If density greatly exceeds threshold)
|
||||||
|
if (active_density > threshold + 60) {
|
||||||
|
// Output 4-6
|
||||||
|
gravity.outputs[inst + 3].High();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current_step = (current_step + 1) % 16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnPlayPress() {
|
||||||
|
if (is_playing) {
|
||||||
|
gravity.clock.Stop();
|
||||||
|
for (int i = 0; i < 6; i++)
|
||||||
|
gravity.outputs[i].Low();
|
||||||
|
} else {
|
||||||
|
gravity.clock.Start();
|
||||||
|
}
|
||||||
|
is_playing = !is_playing;
|
||||||
|
needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnEncoderPress() {
|
||||||
|
editing_param = !editing_param;
|
||||||
|
needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnEncoderRotate(int val) {
|
||||||
|
if (!editing_param) {
|
||||||
|
// Navigate menu (clamp to edges, do not wrap)
|
||||||
|
int next_param = (int)current_param + val;
|
||||||
|
next_param = constrain(next_param, 0, PARAM_LAST - 1);
|
||||||
|
current_param = (SelectedParam)next_param;
|
||||||
|
} else {
|
||||||
|
// Edit parameter
|
||||||
|
int amt = val * 8; // Adjust by 8 values at a time for speed mapping
|
||||||
|
|
||||||
|
switch (current_param) {
|
||||||
|
case PARAM_KICK_DENS:
|
||||||
|
inst_density[0] = constrain(inst_density[0] + amt, 0, 255);
|
||||||
|
break;
|
||||||
|
case PARAM_SNARE_DENS:
|
||||||
|
inst_density[1] = constrain(inst_density[1] + amt, 0, 255);
|
||||||
|
break;
|
||||||
|
case PARAM_HIHAT_DENS:
|
||||||
|
inst_density[2] = constrain(inst_density[2] + amt, 0, 255);
|
||||||
|
break;
|
||||||
|
case PARAM_CHAOS:
|
||||||
|
chaos_amount = constrain(chaos_amount + amt, 0, 255);
|
||||||
|
break;
|
||||||
|
case PARAM_MAP_X:
|
||||||
|
map_x = constrain(map_x + amt, 0, 255);
|
||||||
|
break;
|
||||||
|
case PARAM_MAP_Y:
|
||||||
|
map_y = constrain(map_y + amt, 0, 255);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
eeprom_needs_save = true;
|
||||||
|
last_param_change = millis();
|
||||||
|
}
|
||||||
|
needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DrawBarGraph(int y, const char *label, int value, bool is_selected) {
|
||||||
|
// Reset draw color to default foreground
|
||||||
|
gravity.display.setDrawColor(1);
|
||||||
|
gravity.display.setCursor(0, y);
|
||||||
|
|
||||||
|
if (is_selected) {
|
||||||
|
gravity.display.print(">");
|
||||||
|
if (editing_param) {
|
||||||
|
// Draw solid white box behind the label
|
||||||
|
gravity.display.drawBox(6, y - 8, 26, 10);
|
||||||
|
// Switch to black text to 'cut out' the label from the box
|
||||||
|
gravity.display.setDrawColor(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
gravity.display.print(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
gravity.display.setCursor(6, y);
|
||||||
|
gravity.display.print(label);
|
||||||
|
|
||||||
|
// Restore draw color to white for the bar and text
|
||||||
|
gravity.display.setDrawColor(1);
|
||||||
|
|
||||||
|
// Draw Bar
|
||||||
|
int barLen = map(value, 0, 255, 0, 60);
|
||||||
|
gravity.display.drawFrame(34, y - 8, 60, 8);
|
||||||
|
gravity.display.drawBox(34, y - 8, barLen, 8);
|
||||||
|
|
||||||
|
// Draw value percentage
|
||||||
|
gravity.display.setCursor(98, y);
|
||||||
|
int pct = map(value, 0, 255, 0, 100);
|
||||||
|
gravity.display.print(pct);
|
||||||
|
gravity.display.print("%");
|
||||||
|
}
|
||||||
|
|
||||||
|
void UpdateDisplay() {
|
||||||
|
gravity.display.setFontMode(1);
|
||||||
|
gravity.display.setDrawColor(1);
|
||||||
|
gravity.display.setFont(u8g2_font_5x7_tf);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
gravity.display.setCursor(0, 7);
|
||||||
|
if (is_playing)
|
||||||
|
gravity.display.print("[>] PLAY");
|
||||||
|
else
|
||||||
|
gravity.display.print("[||] PAUS");
|
||||||
|
|
||||||
|
gravity.display.setCursor(55, 7);
|
||||||
|
gravity.display.print("BPM:");
|
||||||
|
gravity.display.print(gravity.clock.Tempo());
|
||||||
|
|
||||||
|
gravity.display.drawHLine(0, 10, 128);
|
||||||
|
|
||||||
|
// Parameters List (Scrollable window of 5 items)
|
||||||
|
int y_start = 20;
|
||||||
|
int y_spacing = 9;
|
||||||
|
|
||||||
|
// Calculate window start index
|
||||||
|
int window_start = max(0, min((int)current_param - 2, PARAM_LAST - 5));
|
||||||
|
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
int param_idx = window_start + i;
|
||||||
|
int y_pos = y_start + (y_spacing * i);
|
||||||
|
bool is_sel = (current_param == param_idx);
|
||||||
|
|
||||||
|
switch (param_idx) {
|
||||||
|
case PARAM_KICK_DENS:
|
||||||
|
DrawBarGraph(y_pos, "KICK", inst_density[0], is_sel);
|
||||||
|
break;
|
||||||
|
case PARAM_SNARE_DENS:
|
||||||
|
DrawBarGraph(y_pos, "SNAR", inst_density[1], is_sel);
|
||||||
|
break;
|
||||||
|
case PARAM_HIHAT_DENS:
|
||||||
|
DrawBarGraph(y_pos, "HHAT", inst_density[2], is_sel);
|
||||||
|
break;
|
||||||
|
case PARAM_CHAOS:
|
||||||
|
DrawBarGraph(y_pos, "CHAO", chaos_amount, is_sel);
|
||||||
|
break;
|
||||||
|
case PARAM_MAP_X:
|
||||||
|
DrawBarGraph(y_pos, "MAPX", map_x, is_sel);
|
||||||
|
break;
|
||||||
|
case PARAM_MAP_Y:
|
||||||
|
DrawBarGraph(y_pos, "MAPY", map_y, is_sel);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
gravity.Init();
|
||||||
|
LoadState();
|
||||||
|
|
||||||
|
gravity.play_button.AttachPressHandler(OnPlayPress);
|
||||||
|
gravity.encoder.AttachPressHandler(OnEncoderPress);
|
||||||
|
gravity.encoder.AttachRotateHandler(OnEncoderRotate);
|
||||||
|
|
||||||
|
gravity.clock.AttachIntHandler(ProcessSequencerTick);
|
||||||
|
// Default to 120 BPM internal
|
||||||
|
gravity.clock.SetTempo(120);
|
||||||
|
gravity.clock.SetSource(Clock::SOURCE_INTERNAL);
|
||||||
|
|
||||||
|
// Speed up I2C for faster OLED refreshing
|
||||||
|
Wire.setClock(400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
gravity.Process();
|
||||||
|
|
||||||
|
// Apply CV modulation
|
||||||
|
// CV1 modulates Map X, CV2 modulates Chaos
|
||||||
|
cv1_val = gravity.cv1.Read(); // -512 to 512
|
||||||
|
cv2_val = gravity.cv2.Read();
|
||||||
|
|
||||||
|
if (eeprom_needs_save && (millis() - last_param_change > SAVE_DELAY_MS)) {
|
||||||
|
SaveState();
|
||||||
|
eeprom_needs_save = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needs_redraw && (millis() - last_redraw > REDRAW_DELAY_MS)) {
|
||||||
|
needs_redraw = false;
|
||||||
|
last_redraw = millis();
|
||||||
|
gravity.display.firstPage();
|
||||||
|
do {
|
||||||
|
UpdateDisplay();
|
||||||
|
} while (gravity.display.nextPage());
|
||||||
|
}
|
||||||
|
}
|
||||||
136
firmware/Rhythm/maps.h
Normal file
136
firmware/Rhythm/maps.h
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
#ifndef MAPS_H
|
||||||
|
#define MAPS_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <avr/pgmspace.h>
|
||||||
|
|
||||||
|
// 4x4 Grid of Patterns (X = Genre, Y = Variation)
|
||||||
|
// X=0: House/Techno (4 on floor)
|
||||||
|
// X=1: Breakbeat/Drum&Bass (Syncopated)
|
||||||
|
// X=2: HipHop/Trap (Half-time feel)
|
||||||
|
// X=3: Dem Bow / Reggaeton (Tresillo rhythm)
|
||||||
|
// Y=0: Sparse/Basic
|
||||||
|
// Y=1: Standard
|
||||||
|
// Y=2: Syncopated/Groove
|
||||||
|
// Y=3: Busy/Fills
|
||||||
|
|
||||||
|
// 3 Instruments (Kick=0, Snare=1, HiHat=2)
|
||||||
|
// 16 Steps per pattern.
|
||||||
|
// Values are "Thresholds" (0-255). A step fires if the channel's Density setting > Threshold.
|
||||||
|
// A Threshold of 255 means it NEVER fires. 0 means it ALWAYS fires (even at minimum density).
|
||||||
|
|
||||||
|
const uint8_t PATTERN_MAPS[4][4][3][16] PROGMEM = {
|
||||||
|
// X = 0 (House)
|
||||||
|
{
|
||||||
|
// Y = 0 (Sparse)
|
||||||
|
{
|
||||||
|
{ 20, 255, 255, 255, 20, 255, 255, 255, 20, 255, 255, 255, 20, 255, 255, 255 }, // Kick
|
||||||
|
{ 255, 255, 255, 255, 50, 255, 255, 255, 255, 255, 255, 255, 50, 255, 255, 255 }, // Snare
|
||||||
|
{ 255, 255, 60, 255, 255, 255, 60, 255, 255, 255, 60, 255, 255, 255, 60, 255 } // HiHat (Offbeats)
|
||||||
|
},
|
||||||
|
// Y = 1 (Standard)
|
||||||
|
{
|
||||||
|
{ 10, 255, 200, 255, 10, 255, 200, 255, 10, 255, 200, 255, 10, 255, 200, 255 }, // Kick
|
||||||
|
{ 255, 255, 255, 255, 30, 255, 255, 200, 255, 255, 255, 255, 30, 255, 255, 200 }, // Snare
|
||||||
|
{ 150, 80, 40, 80, 150, 80, 40, 80, 150, 80, 40, 80, 150, 80, 40, 80 } // HiHat
|
||||||
|
},
|
||||||
|
// Y = 2 (Syncopated Break/Groove)
|
||||||
|
{
|
||||||
|
{ 10, 255, 180, 255, 255, 180, 150, 255, 10, 255, 180, 200, 255, 180, 150, 255 }, // Kick (pushing beats)
|
||||||
|
{ 255, 200, 255, 200, 20, 200, 255, 150, 255, 200, 255, 200, 20, 200, 255, 150 }, // Snare (ghost notes)
|
||||||
|
{ 60, 150, 20, 150, 80, 150, 20, 150, 60, 150, 20, 150, 80, 150, 20, 150 } // HiHat (dynamic 16ths)
|
||||||
|
},
|
||||||
|
// Y = 3 (Busy / Driving)
|
||||||
|
{
|
||||||
|
{ 5, 180, 150, 200, 5, 180, 150, 200, 5, 180, 150, 200, 5, 180, 150, 200 }, // Kick
|
||||||
|
{ 255, 200, 200, 255, 20, 200, 200, 180, 255, 200, 200, 255, 20, 200, 200, 180 }, // Snare
|
||||||
|
{ 20, 60, 10, 60, 20, 60, 10, 60, 20, 60, 10, 60, 20, 60, 10, 60 } // HiHat (accented 16ths)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// X = 1 (Breakbeat)
|
||||||
|
{
|
||||||
|
// Y = 0 (Sparse)
|
||||||
|
{
|
||||||
|
{ 20, 255, 255, 255, 255, 255, 100, 255, 255, 255, 255, 255, 255, 255, 255, 255 }, // Kick (1, 3& half)
|
||||||
|
{ 255, 255, 255, 255, 50, 255, 255, 255, 255, 255, 255, 255, 50, 255, 255, 255 }, // Snare (2, 4)
|
||||||
|
{ 60, 255, 150, 255, 60, 255, 150, 255, 60, 255, 150, 255, 60, 255, 150, 255 } // HiHat (8ths)
|
||||||
|
},
|
||||||
|
// Y = 1 (Standard)
|
||||||
|
{
|
||||||
|
{ 10, 255, 150, 255, 255, 180, 80, 200, 255, 150, 255, 200, 255, 255, 150, 255 }, // Kick
|
||||||
|
{ 255, 255, 200, 255, 30, 255, 255, 150, 255, 255, 180, 255, 30, 255, 200, 180 }, // Snare (syncopated ghosts)
|
||||||
|
{ 40, 120, 80, 120, 40, 120, 80, 120, 40, 120, 80, 120, 40, 120, 80, 120 } // HiHat
|
||||||
|
},
|
||||||
|
// Y = 2 (Jungle / Fast Syncopation)
|
||||||
|
{
|
||||||
|
{ 10, 200, 255, 150, 255, 200, 80, 255, 10, 200, 255, 150, 255, 200, 80, 200 }, // Kick
|
||||||
|
{ 255, 150, 200, 180, 20, 200, 150, 100, 255, 150, 200, 180, 20, 200, 150, 100 }, // Snare (heavy ghosts & rolls)
|
||||||
|
{ 50, 150, 150, 100, 50, 150, 150, 100, 50, 150, 150, 100, 50, 150, 150, 100 } // HiHat (dynamic 16ths)
|
||||||
|
},
|
||||||
|
// Y = 3 (Busy Breakcore)
|
||||||
|
{
|
||||||
|
{ 5, 150, 120, 150, 180, 100, 50, 120, 150, 80, 150, 100, 200, 80, 100, 150 }, // Kick
|
||||||
|
{ 200, 180, 150, 200, 20, 150, 180, 100, 180, 120, 100, 150, 20, 100, 120, 80 }, // Snare
|
||||||
|
{ 20, 50, 40, 50, 20, 50, 40, 50, 20, 50, 40, 50, 20, 50, 40, 50 } // HiHat (16ths + dynamic)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// X = 2 (HipHop)
|
||||||
|
{
|
||||||
|
// Y = 0 (Sparse)
|
||||||
|
{
|
||||||
|
{ 20, 255, 255, 255, 255, 255, 255, 255, 100, 255, 255, 255, 255, 255, 255, 255 }, // Kick (1, 3)
|
||||||
|
{ 255, 255, 255, 255, 60, 255, 255, 255, 255, 255, 255, 255, 60, 255, 255, 255 }, // Snare (2, 4)
|
||||||
|
{ 80, 255, 255, 255, 80, 255, 255, 255, 80, 255, 255, 255, 80, 255, 255, 255 } // HiHat (Quarters)
|
||||||
|
},
|
||||||
|
// Y = 1 (Standard)
|
||||||
|
{
|
||||||
|
{ 10, 255, 255, 150, 255, 255, 255, 200, 80, 255, 150, 255, 255, 255, 255, 200 }, // Kick
|
||||||
|
{ 255, 255, 255, 255, 40, 255, 255, 255, 255, 255, 255, 255, 40, 255, 200, 255 }, // Snare
|
||||||
|
{ 50, 150, 50, 150, 50, 150, 50, 150, 50, 150, 50, 150, 50, 150, 50, 150 } // HiHat (8ths)
|
||||||
|
},
|
||||||
|
// Y = 2 (Boom Bap / Swing)
|
||||||
|
{
|
||||||
|
{ 10, 255, 150, 200, 255, 255, 150, 200, 50, 255, 100, 255, 255, 200, 150, 255 }, // Kick (Swung 16ths)
|
||||||
|
{ 255, 255, 200, 255, 30, 255, 200, 150, 255, 255, 200, 255, 30, 200, 255, 150 }, // Snare (Ghost notes)
|
||||||
|
{ 30, 200, 80, 150, 30, 200, 80, 150, 30, 200, 80, 150, 30, 200, 80, 150 } // HiHat (Heavy swing feel)
|
||||||
|
},
|
||||||
|
// Y = 3 (Busy / Trap)
|
||||||
|
{
|
||||||
|
{ 5, 200, 150, 80, 200, 180, 150, 120, 40, 150, 80, 200, 150, 180, 120, 100 }, // Kick
|
||||||
|
{ 255, 255, 200, 255, 20, 200, 255, 150, 255, 255, 180, 255, 20, 150, 180, 100 }, // Snare (with trap rolls)
|
||||||
|
{ 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20 } // HiHat (rapid 16ths)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// X = 3 (Dem Bow / Reggaeton - Tresillo Rhythm)
|
||||||
|
{
|
||||||
|
// Y = 0 (Sparse)
|
||||||
|
{
|
||||||
|
{ 20, 255, 255, 255, 20, 255, 255, 255, 20, 255, 255, 255, 20, 255, 255, 255 }, // Kick (4 on floor)
|
||||||
|
{ 255, 255, 255, 60, 255, 255, 60, 255, 255, 255, 255, 60, 255, 255, 60, 255 }, // Snare (Tresillo off-beats)
|
||||||
|
{ 255, 255, 150, 255, 255, 255, 150, 255, 255, 255, 150, 255, 255, 255, 150, 255 } // HiHat (basic offbeats)
|
||||||
|
},
|
||||||
|
// Y = 1 (Standard)
|
||||||
|
{
|
||||||
|
{ 10, 255, 200, 255, 10, 255, 200, 255, 10, 255, 200, 255, 10, 255, 200, 255 }, // Kick
|
||||||
|
{ 255, 255, 255, 30, 255, 255, 30, 200, 255, 255, 255, 30, 255, 255, 30, 200 }, // Snare (Strong Tresillo + syncopation)
|
||||||
|
{ 100, 200, 50, 200, 100, 200, 50, 200, 100, 200, 50, 200, 100, 200, 50, 200 } // HiHat (8th note swing)
|
||||||
|
},
|
||||||
|
// Y = 2 (Syncopated Tresillo)
|
||||||
|
{
|
||||||
|
{ 10, 255, 150, 200, 10, 255, 150, 200, 10, 255, 150, 200, 10, 255, 150, 200 }, // Kick (Driving but syncopated)
|
||||||
|
{ 255, 200, 255, 20, 200, 255, 20, 150, 255, 200, 255, 20, 200, 255, 20, 150 }, // Snare (Pushing the backbeat)
|
||||||
|
{ 50, 150, 100, 150, 50, 150, 100, 150, 50, 150, 100, 150, 50, 150, 100, 150 } // HiHat (Syncopated ride)
|
||||||
|
},
|
||||||
|
// Y = 3 (Busy / Rolls)
|
||||||
|
{
|
||||||
|
{ 5, 200, 150, 120, 5, 200, 150, 120, 5, 200, 150, 120, 5, 200, 150, 120 }, // Kick (driving)
|
||||||
|
{ 255, 180, 200, 20, 180, 200, 20, 120, 255, 180, 200, 20, 180, 200, 20, 80 }, // Snare (Dem Bow rolls)
|
||||||
|
{ 30, 80, 40, 80, 30, 80, 40, 80, 30, 80, 40, 80, 30, 80, 40, 80 } // HiHat (16ths riding on the tresillo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
@ -1,5 +1,4 @@
|
|||||||
name=libGravity
|
version=2.0.1
|
||||||
version=2.0.0beta3
|
|
||||||
author=Adam Wonak
|
author=Adam Wonak
|
||||||
maintainer=awonak <github.com/awonak>
|
maintainer=awonak <github.com/awonak>
|
||||||
sentence=Hardware abstraction library for Sitka Instruments Gravity eurorack module
|
sentence=Hardware abstraction library for Sitka Instruments Gravity eurorack module
|
||||||
|
|||||||
@ -11,92 +11,107 @@
|
|||||||
#ifndef ANALOG_INPUT_H
|
#ifndef ANALOG_INPUT_H
|
||||||
#define ANALOG_INPUT_H
|
#define ANALOG_INPUT_H
|
||||||
|
|
||||||
const int MAX_INPUT = (1 << 10) - 1; // Max 10 bit analog read resolution.
|
const int MAX_INPUT = (1 << 10) - 1; // Max 10 bit analog read resolution.
|
||||||
|
|
||||||
// estimated default calibration value
|
// estimated default calibration value
|
||||||
const int CALIBRATED_LOW = -566;
|
const int CALIBRATED_LOW = -566;
|
||||||
const int CALIBRATED_HIGH = 512;
|
const int CALIBRATED_HIGH = 512;
|
||||||
|
|
||||||
class AnalogInput {
|
class AnalogInput {
|
||||||
public:
|
public:
|
||||||
static const int GATE_THRESHOLD = 0;
|
static const int GATE_THRESHOLD = 0;
|
||||||
|
|
||||||
AnalogInput() {}
|
AnalogInput() {}
|
||||||
~AnalogInput() {}
|
~AnalogInput() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes a analog input object.
|
* Initializes a analog input object.
|
||||||
*
|
*
|
||||||
* @param pin gpio pin for the analog input.
|
* @param pin gpio pin for the analog input.
|
||||||
*/
|
*/
|
||||||
void Init(uint8_t pin) {
|
void Init(uint8_t pin) {
|
||||||
pinMode(pin, INPUT);
|
pinMode(pin, INPUT);
|
||||||
pin_ = pin;
|
pin_ = pin;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the value of the analog input and set instance state.
|
* Read the value of the analog input and set instance state.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
void Process() {
|
void Process() {
|
||||||
old_read_ = read_;
|
old_read_ = read_;
|
||||||
int raw = analogRead(pin_);
|
int raw = analogRead(pin_);
|
||||||
read_ = map(raw, 0, MAX_INPUT, low_, high_);
|
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
|
||||||
if (inverted_) read_ = -read_;
|
read_ = constrain((long)read_ - (long)offset_, -512, 512);
|
||||||
}
|
if (inverted_)
|
||||||
|
read_ = -read_;
|
||||||
|
}
|
||||||
|
|
||||||
// Set calibration values.
|
// Set calibration values.
|
||||||
|
|
||||||
void AdjustCalibrationLow(int amount) { low_ += amount; }
|
void AdjustCalibrationLow(int amount) { low_ += amount; }
|
||||||
|
|
||||||
void AdjustCalibrationHigh(int amount) { high_ += amount; }
|
void AdjustCalibrationHigh(int amount) { high_ += amount; }
|
||||||
|
|
||||||
void SetOffset(float percent) { offset_ = -(percent)*512; }
|
void SetCalibrationLow(int low) { low_ = low; }
|
||||||
|
|
||||||
void SetAttenuation(float percent) {
|
void SetCalibrationHigh(int high) { high_ = high; }
|
||||||
low_ = abs(percent) * CALIBRATED_LOW;
|
|
||||||
high_ = abs(percent) * CALIBRATED_HIGH;
|
|
||||||
inverted_ = percent < 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
int GetCalibrationLow() const { return low_; }
|
||||||
* Get the current value of the analog input within a range of +/-512.
|
|
||||||
*
|
|
||||||
* @return read value within a range of +/-512.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
inline int16_t Read() { return read_; }
|
|
||||||
|
|
||||||
/**
|
int GetCalibrationHigh() const { return high_; }
|
||||||
* Return the analog read value as voltage.
|
|
||||||
*
|
|
||||||
* @return A float representing the voltage (-5.0 to +5.0).
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
inline float Voltage() { return ((read_ / 512.0) * 5.0); }
|
|
||||||
|
|
||||||
/**
|
void SetOffset(float percent) { offset_ = -(percent) * 512; }
|
||||||
* Checks for a rising edge transition across a threshold.
|
|
||||||
*
|
|
||||||
* @param threshold The value that the input must cross.
|
|
||||||
* @return True if the value just crossed the threshold from below, false otherwise.
|
|
||||||
*/
|
|
||||||
inline bool IsRisingEdge(int16_t threshold) const {
|
|
||||||
bool was_high = old_read_ > threshold;
|
|
||||||
bool is_high = read_ > threshold;
|
|
||||||
return is_high && !was_high;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
void AdjustOffset(int amount) { offset_ += amount; }
|
||||||
uint8_t pin_;
|
|
||||||
int16_t read_;
|
int GetOffset() const { return offset_; }
|
||||||
uint16_t old_read_;
|
|
||||||
// calibration values.
|
void SetAttenuation(float percent) {
|
||||||
int offset_ = 0;
|
low_ = abs(percent) * CALIBRATED_LOW;
|
||||||
int low_ = CALIBRATED_LOW;
|
high_ = abs(percent) * CALIBRATED_HIGH;
|
||||||
int high_ = CALIBRATED_HIGH;
|
inverted_ = percent < 0;
|
||||||
bool inverted_ = false;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current value of the analog input within a range of +/-512.
|
||||||
|
*
|
||||||
|
* @return read value within a range of +/-512.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
inline int16_t Read() { return read_; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the analog read value as voltage.
|
||||||
|
*
|
||||||
|
* @return A float representing the voltage (-5.0 to +5.0).
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
inline float Voltage() { return ((read_ / 512.0) * 5.0); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks for a rising edge transition across a threshold.
|
||||||
|
*
|
||||||
|
* @param threshold The value that the input must cross.
|
||||||
|
* @return True if the value just crossed the threshold from below, false
|
||||||
|
* otherwise.
|
||||||
|
*/
|
||||||
|
inline bool IsRisingEdge(int16_t threshold) const {
|
||||||
|
bool was_high = old_read_ > threshold;
|
||||||
|
bool is_high = read_ > threshold;
|
||||||
|
return is_high && !was_high;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
uint8_t pin_;
|
||||||
|
int16_t read_;
|
||||||
|
uint16_t old_read_;
|
||||||
|
// calibration values.
|
||||||
|
int offset_ = 0;
|
||||||
|
int low_ = CALIBRATED_LOW;
|
||||||
|
int high_ = CALIBRATED_HIGH;
|
||||||
|
bool inverted_ = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
295
src/clock.h
295
src/clock.h
@ -17,7 +17,8 @@
|
|||||||
#include "peripherials.h"
|
#include "peripherials.h"
|
||||||
#include "uClock/uClock.h"
|
#include "uClock/uClock.h"
|
||||||
|
|
||||||
// MIDI clock, start, stop, and continue byte definitions - based on MIDI 1.0 Standards.
|
// MIDI clock, start, stop, and continue byte definitions - based on MIDI 1.0
|
||||||
|
// Standards.
|
||||||
#define MIDI_CLOCK 0xF8
|
#define MIDI_CLOCK 0xF8
|
||||||
#define MIDI_START 0xFA
|
#define MIDI_START 0xFA
|
||||||
#define MIDI_STOP 0xFC
|
#define MIDI_STOP 0xFC
|
||||||
@ -28,174 +29,158 @@ static ExtCallback extUserCallback = nullptr;
|
|||||||
static void serialEventNoop(uint8_t msg, uint8_t status) {}
|
static void serialEventNoop(uint8_t msg, uint8_t status) {}
|
||||||
|
|
||||||
class Clock {
|
class Clock {
|
||||||
public:
|
public:
|
||||||
static constexpr int DEFAULT_TEMPO = 120;
|
static constexpr int DEFAULT_TEMPO = 120;
|
||||||
|
|
||||||
enum Source {
|
enum Source {
|
||||||
SOURCE_INTERNAL,
|
SOURCE_INTERNAL,
|
||||||
SOURCE_EXTERNAL_PPQN_24,
|
SOURCE_EXTERNAL_PPQN_24,
|
||||||
SOURCE_EXTERNAL_PPQN_4,
|
SOURCE_EXTERNAL_PPQN_4,
|
||||||
SOURCE_EXTERNAL_PPQN_2,
|
SOURCE_EXTERNAL_PPQN_2,
|
||||||
SOURCE_EXTERNAL_PPQN_1,
|
SOURCE_EXTERNAL_PPQN_1,
|
||||||
SOURCE_EXTERNAL_MIDI,
|
SOURCE_EXTERNAL_MIDI,
|
||||||
SOURCE_LAST,
|
SOURCE_LAST,
|
||||||
};
|
};
|
||||||
|
|
||||||
enum Pulse {
|
enum Pulse {
|
||||||
PULSE_NONE,
|
PULSE_NONE,
|
||||||
PULSE_PPQN_1,
|
PULSE_PPQN_1,
|
||||||
PULSE_PPQN_4,
|
PULSE_PPQN_4,
|
||||||
PULSE_PPQN_24,
|
PULSE_PPQN_24,
|
||||||
PULSE_LAST,
|
PULSE_LAST,
|
||||||
};
|
};
|
||||||
|
|
||||||
void Init() {
|
void Init() {
|
||||||
NeoSerial.begin(31250);
|
NeoSerial.begin(31250);
|
||||||
|
|
||||||
// Initialize the clock library
|
// Initialize the clock library
|
||||||
uClock.init();
|
uClock.init();
|
||||||
uClock.setClockMode(uClock.INTERNAL_CLOCK);
|
uClock.setClockMode(uClock.INTERNAL_CLOCK);
|
||||||
uClock.setOutputPPQN(uClock.PPQN_96);
|
uClock.setOutputPPQN(uClock.PPQN_96);
|
||||||
uClock.setTempo(DEFAULT_TEMPO);
|
uClock.setTempo(DEFAULT_TEMPO);
|
||||||
|
|
||||||
// MIDI events.
|
// MIDI events.
|
||||||
uClock.setOnClockStart(sendMIDIStart);
|
uClock.setOnClockStart(sendMIDIStart);
|
||||||
uClock.setOnClockStop(sendMIDIStop);
|
uClock.setOnClockStop(sendMIDIStop);
|
||||||
uClock.setOnSync24(sendMIDIClock);
|
uClock.setOnSync24(sendMIDIClock);
|
||||||
|
|
||||||
uClock.start();
|
uClock.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle external clock tick and call user callback when receiving clock
|
||||||
|
// trigger (PPQN_4, PPQN_24, or MIDI).
|
||||||
|
void AttachExtHandler(void (*callback)()) {
|
||||||
|
extUserCallback = callback;
|
||||||
|
attachInterrupt(digitalPinToInterrupt(EXT_PIN), callback, RISING);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal PPQN96 callback for all clock timer operations.
|
||||||
|
void AttachIntHandler(void (*callback)(uint32_t)) {
|
||||||
|
uClock.setOnOutputPPQN(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the source of the clock mode.
|
||||||
|
void SetSource(Source source) {
|
||||||
|
bool was_playing = !IsPaused();
|
||||||
|
uClock.stop();
|
||||||
|
// If we are changing the source from MIDI, disable the serial interrupt
|
||||||
|
// handler.
|
||||||
|
if (source_ == SOURCE_EXTERNAL_MIDI) {
|
||||||
|
NeoSerial.attachInterrupt(serialEventNoop);
|
||||||
}
|
}
|
||||||
|
source_ = source;
|
||||||
// Handle external clock tick and call user callback when receiving clock trigger (PPQN_4, PPQN_24, or MIDI).
|
switch (source) {
|
||||||
void AttachExtHandler(void (*callback)()) {
|
case SOURCE_INTERNAL:
|
||||||
extUserCallback = callback;
|
uClock.setClockMode(uClock.INTERNAL_CLOCK);
|
||||||
attachInterrupt(digitalPinToInterrupt(EXT_PIN), callback, RISING);
|
break;
|
||||||
|
case SOURCE_EXTERNAL_PPQN_24:
|
||||||
|
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
|
||||||
|
uClock.setInputPPQN(uClock.PPQN_24);
|
||||||
|
break;
|
||||||
|
case SOURCE_EXTERNAL_PPQN_4:
|
||||||
|
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
|
||||||
|
uClock.setInputPPQN(uClock.PPQN_4);
|
||||||
|
break;
|
||||||
|
case SOURCE_EXTERNAL_PPQN_2:
|
||||||
|
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
|
||||||
|
uClock.setInputPPQN(uClock.PPQN_2);
|
||||||
|
break;
|
||||||
|
case SOURCE_EXTERNAL_PPQN_1:
|
||||||
|
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
|
||||||
|
uClock.setInputPPQN(uClock.PPQN_1);
|
||||||
|
break;
|
||||||
|
case SOURCE_EXTERNAL_MIDI:
|
||||||
|
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
|
||||||
|
uClock.setInputPPQN(uClock.PPQN_24);
|
||||||
|
NeoSerial.attachInterrupt(onSerialEvent);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
if (was_playing) {
|
||||||
// Internal PPQN96 callback for all clock timer operations.
|
uClock.start();
|
||||||
void AttachIntHandler(void (*callback)(uint32_t)) {
|
|
||||||
uClock.setOnOutputPPQN(callback);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set the source of the clock mode.
|
// Return true if the current selected source is externl (PPQN_4, PPQN_24, or
|
||||||
void SetSource(Source source) {
|
// MIDI).
|
||||||
bool was_playing = !IsPaused();
|
bool ExternalSource() {
|
||||||
uClock.stop();
|
return uClock.getClockMode() == uClock.EXTERNAL_CLOCK;
|
||||||
// If we are changing the source from MIDI, disable the serial interrupt handler.
|
}
|
||||||
if (source_ == SOURCE_EXTERNAL_MIDI) {
|
|
||||||
NeoSerial.attachInterrupt(serialEventNoop);
|
// Return true if the current selected source is the internal master clock.
|
||||||
}
|
bool InternalSource() {
|
||||||
source_ = source;
|
return uClock.getClockMode() == uClock.INTERNAL_CLOCK;
|
||||||
switch (source) {
|
}
|
||||||
case SOURCE_INTERNAL:
|
|
||||||
uClock.setClockMode(uClock.INTERNAL_CLOCK);
|
// Returns the current BPM tempo.
|
||||||
break;
|
int Tempo() { return uClock.getTempo(); }
|
||||||
case SOURCE_EXTERNAL_PPQN_24:
|
|
||||||
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
|
// Set the clock tempo to a int between 1 and 400.
|
||||||
uClock.setInputPPQN(uClock.PPQN_24);
|
void SetTempo(int tempo) { return uClock.setTempo(tempo); }
|
||||||
break;
|
|
||||||
case SOURCE_EXTERNAL_PPQN_4:
|
// Record an external clock tick received to process external/internal
|
||||||
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
|
// syncronization.
|
||||||
uClock.setInputPPQN(uClock.PPQN_4);
|
void Tick() { uClock.clockMe(); }
|
||||||
break;
|
|
||||||
case SOURCE_EXTERNAL_PPQN_2:
|
// Start the internal clock.
|
||||||
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
|
void Start() { uClock.start(); }
|
||||||
uClock.setInputPPQN(uClock.PPQN_2);
|
|
||||||
break;
|
// Stop internal clock clock.
|
||||||
case SOURCE_EXTERNAL_PPQN_1:
|
void Stop() { uClock.stop(); }
|
||||||
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
|
|
||||||
uClock.setInputPPQN(uClock.PPQN_1);
|
// Reset all clock counters to 0.
|
||||||
break;
|
void Reset() { uClock.resetCounters(); }
|
||||||
case SOURCE_EXTERNAL_MIDI:
|
|
||||||
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
|
// Returns true if the clock is not running.
|
||||||
uClock.setInputPPQN(uClock.PPQN_24);
|
bool IsPaused() { return uClock.clock_state == uClock.PAUSED; }
|
||||||
NeoSerial.attachInterrupt(onSerialEvent);
|
|
||||||
break;
|
private:
|
||||||
}
|
Source source_ = SOURCE_INTERNAL;
|
||||||
if (was_playing) {
|
|
||||||
uClock.start();
|
static void onSerialEvent(uint8_t msg, uint8_t status) {
|
||||||
}
|
// Note: uClock start and stop will echo to MIDI.
|
||||||
|
switch (msg) {
|
||||||
|
case MIDI_CLOCK:
|
||||||
|
if (extUserCallback) {
|
||||||
|
extUserCallback();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case MIDI_STOP:
|
||||||
|
uClock.stop();
|
||||||
|
sendMIDIStop();
|
||||||
|
break;
|
||||||
|
case MIDI_START:
|
||||||
|
case MIDI_CONTINUE:
|
||||||
|
uClock.start();
|
||||||
|
sendMIDIStart();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Return true if the current selected source is externl (PPQN_4, PPQN_24, or MIDI).
|
static void sendMIDIStart() { NeoSerial.write(MIDI_START); }
|
||||||
bool ExternalSource() {
|
|
||||||
return uClock.getClockMode() == uClock.EXTERNAL_CLOCK;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return true if the current selected source is the internal master clock.
|
static void sendMIDIStop() { NeoSerial.write(MIDI_STOP); }
|
||||||
bool InternalSource() {
|
|
||||||
return uClock.getClockMode() == uClock.INTERNAL_CLOCK;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the current BPM tempo.
|
static void sendMIDIClock(uint32_t tick) { NeoSerial.write(MIDI_CLOCK); }
|
||||||
int Tempo() {
|
|
||||||
return uClock.getTempo();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the clock tempo to a int between 1 and 400.
|
|
||||||
void SetTempo(int tempo) {
|
|
||||||
return uClock.setTempo(tempo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record an external clock tick received to process external/internal syncronization.
|
|
||||||
void Tick() {
|
|
||||||
uClock.clockMe();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start the internal clock.
|
|
||||||
void Start() {
|
|
||||||
uClock.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop internal clock clock.
|
|
||||||
void Stop() {
|
|
||||||
uClock.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset all clock counters to 0.
|
|
||||||
void Reset() {
|
|
||||||
uClock.resetCounters();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns true if the clock is not running.
|
|
||||||
bool IsPaused() {
|
|
||||||
return uClock.clock_state == uClock.PAUSED;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
Source source_ = SOURCE_INTERNAL;
|
|
||||||
|
|
||||||
static void onSerialEvent(uint8_t msg, uint8_t status) {
|
|
||||||
// Note: uClock start and stop will echo to MIDI.
|
|
||||||
switch (msg) {
|
|
||||||
case MIDI_CLOCK:
|
|
||||||
if (extUserCallback) {
|
|
||||||
extUserCallback();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case MIDI_STOP:
|
|
||||||
uClock.stop();
|
|
||||||
sendMIDIStop();
|
|
||||||
break;
|
|
||||||
case MIDI_START:
|
|
||||||
case MIDI_CONTINUE:
|
|
||||||
uClock.start();
|
|
||||||
sendMIDIStart();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void sendMIDIStart() {
|
|
||||||
NeoSerial.write(MIDI_START);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void sendMIDIStop() {
|
|
||||||
NeoSerial.write(MIDI_STOP);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void sendMIDIClock(uint32_t tick) {
|
|
||||||
NeoSerial.write(MIDI_CLOCK);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@ -16,78 +16,81 @@
|
|||||||
const byte DEFAULT_TRIGGER_DURATION_MS = 5;
|
const byte DEFAULT_TRIGGER_DURATION_MS = 5;
|
||||||
|
|
||||||
class DigitalOutput {
|
class DigitalOutput {
|
||||||
public:
|
public:
|
||||||
/**
|
/**
|
||||||
* Initializes an CV Output paired object.
|
* Initializes an CV Output paired object.
|
||||||
*
|
*
|
||||||
* @param cv_pin gpio pin for the cv output
|
* @param cv_pin gpio pin for the cv output
|
||||||
*/
|
*/
|
||||||
void Init(uint8_t cv_pin) {
|
void Init(uint8_t cv_pin) {
|
||||||
pinMode(cv_pin, OUTPUT); // Gate/Trigger Output
|
pinMode(cv_pin, OUTPUT); // Gate/Trigger Output
|
||||||
cv_pin_ = cv_pin;
|
cv_pin_ = cv_pin;
|
||||||
trigger_duration_ = DEFAULT_TRIGGER_DURATION_MS;
|
trigger_duration_ = DEFAULT_TRIGGER_DURATION_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the trigger duration in miliseconds.
|
||||||
|
*
|
||||||
|
* @param duration_ms trigger duration in miliseconds
|
||||||
|
*/
|
||||||
|
void SetTriggerDuration(uint8_t duration_ms) {
|
||||||
|
trigger_duration_ = duration_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the CV and LED on or off according to the input state.
|
||||||
|
*
|
||||||
|
* @param state Arduino digital HIGH or LOW values.
|
||||||
|
*/
|
||||||
|
inline void Update(uint8_t state) {
|
||||||
|
if (state == HIGH)
|
||||||
|
High(); // Rising
|
||||||
|
if (state == LOW)
|
||||||
|
Low(); // Falling
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets the cv output HIGH to about 5v.
|
||||||
|
inline void High() { update(HIGH); }
|
||||||
|
|
||||||
|
// Sets the cv output LOW to 0v.
|
||||||
|
inline void Low() { update(LOW); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begin a Trigger period for this output.
|
||||||
|
*/
|
||||||
|
inline void Trigger() {
|
||||||
|
update(HIGH);
|
||||||
|
last_triggered_ = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a bool representing the on/off state of the output.
|
||||||
|
*/
|
||||||
|
inline void Process() {
|
||||||
|
// If trigger is HIGH and the trigger duration time has elapsed, set the
|
||||||
|
// output low.
|
||||||
|
if (on_ && (millis() - last_triggered_) >= trigger_duration_) {
|
||||||
|
update(LOW);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the trigger duration in miliseconds.
|
* Return a bool representing the on/off state of the output.
|
||||||
*
|
*
|
||||||
* @param duration_ms trigger duration in miliseconds
|
* @return true if current cv state is high, false if current cv state is low
|
||||||
*/
|
*/
|
||||||
void SetTriggerDuration(uint8_t duration_ms) {
|
inline bool On() { return on_; }
|
||||||
trigger_duration_ = duration_ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
private:
|
||||||
* Turn the CV and LED on or off according to the input state.
|
unsigned long last_triggered_;
|
||||||
*
|
uint8_t trigger_duration_;
|
||||||
* @param state Arduino digital HIGH or LOW values.
|
uint8_t cv_pin_;
|
||||||
*/
|
bool on_;
|
||||||
inline void Update(uint8_t state) {
|
|
||||||
if (state == HIGH) High(); // Rising
|
|
||||||
if (state == LOW) Low(); // Falling
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sets the cv output HIGH to about 5v.
|
void update(uint8_t state) {
|
||||||
inline void High() { update(HIGH); }
|
digitalWrite(cv_pin_, state);
|
||||||
|
on_ = state == HIGH;
|
||||||
// Sets the cv output LOW to 0v.
|
}
|
||||||
inline void Low() { update(LOW); }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Begin a Trigger period for this output.
|
|
||||||
*/
|
|
||||||
inline void Trigger() {
|
|
||||||
update(HIGH);
|
|
||||||
last_triggered_ = millis();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a bool representing the on/off state of the output.
|
|
||||||
*/
|
|
||||||
inline void Process() {
|
|
||||||
// If trigger is HIGH and the trigger duration time has elapsed, set the output low.
|
|
||||||
if (on_ && (millis() - last_triggered_) >= trigger_duration_) {
|
|
||||||
update(LOW);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a bool representing the on/off state of the output.
|
|
||||||
*
|
|
||||||
* @return true if current cv state is high, false if current cv state is low
|
|
||||||
*/
|
|
||||||
inline bool On() { return on_; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
unsigned long last_triggered_;
|
|
||||||
uint8_t trigger_duration_;
|
|
||||||
uint8_t cv_pin_;
|
|
||||||
bool on_;
|
|
||||||
|
|
||||||
void update(uint8_t state) {
|
|
||||||
digitalWrite(cv_pin_, state);
|
|
||||||
on_ = state == HIGH;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
Reference in New Issue
Block a user