This commit is contained in:
2025-07-04 10:44:08 -07:00
7 changed files with 216 additions and 119 deletions

View File

@ -6,20 +6,20 @@
* @date 2025-05-04 * @date 2025-05-04
* *
* @copyright Copyright (c) 2025 * @copyright Copyright (c) 2025
* *
* 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
* create an open source friendly version of the firmware that makes it easy * 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 * for users/developers to modify and create their own original alt firmware
* implementations. * implementations.
* *
* The libGravity library represents wrappers around the * The libGravity library represents wrappers around the
* hardware peripherials to make it easy to interact with and add behavior * 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 * to them. The library tries not to make any assumptions about what the
* firmware can or should do. * firmware can or should do.
* *
* The Gravity firmware is a slightly different implementation of the original * The Gravity firmware is a slightly different implementation of the original
* firmware. There are a few notable changes; the internal clock operates at * 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 * 96 PPQN instead of the original 24 PPQN, which allows for more granular
* quantization of features like duty cycle (pulse width) or offset. * quantization of features like duty cycle (pulse width) or offset.
* Additionally, this firmware replaces the sequencer with a Euclidean Rhythm * Additionally, this firmware replaces the sequencer with a Euclidean Rhythm
@ -167,11 +167,24 @@ 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
if (app.selected_param == PARAM_MAIN_ENCODER_DIR) { if (app.selected_param == PARAM_MAIN_ENCODER_DIR) {
bool reversed = app.selected_sub_param == 1; bool reversed = app.selected_sub_param == 1;
gravity.encoder.SetReverseDirection(reversed); gravity.encoder.SetReverseDirection(reversed);
} }
// Reset state if (app.selected_param == PARAM_MAIN_SAVE_DATA) {
if (app.selected_sub_param < 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 < MAX_SAVE_SLOTS) {
app.selected_save_slot = app.selected_sub_param;
stateManager.loadData(app, app.selected_save_slot);
InitGravity(app);
}
}
if (app.selected_param == PARAM_MAIN_RESET_STATE) { if (app.selected_param == PARAM_MAIN_RESET_STATE) {
if (app.selected_sub_param == 0) { // Reset if (app.selected_sub_param == 0) { // Reset
stateManager.reset(app); stateManager.reset(app);
@ -179,10 +192,11 @@ void HandleEncoderPressed() {
} }
} }
} }
// Only mark dirty when leaving editing mode. // Only mark dirty and reset selected_sub_param when leaving editing mode.
stateManager.markDirty(); stateManager.markDirty();
app.selected_sub_param = 0;
} }
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;
} }
@ -225,7 +239,6 @@ void editMainParameter(int val) {
gravity.clock.SetTempo(gravity.clock.Tempo() + val); gravity.clock.SetTempo(gravity.clock.Tempo() + val);
app.tempo = gravity.clock.Tempo(); app.tempo = gravity.clock.Tempo();
break; break;
case PARAM_MAIN_SOURCE: { case PARAM_MAIN_SOURCE: {
byte source = static_cast<int>(app.selected_source); byte source = static_cast<int>(app.selected_source);
updateSelection(source, val, Clock::SOURCE_LAST); updateSelection(source, val, Clock::SOURCE_LAST);
@ -240,10 +253,15 @@ void editMainParameter(int val) {
if (app.selected_pulse == Clock::PULSE_NONE) { if (app.selected_pulse == Clock::PULSE_NONE) {
gravity.pulse.Low(); gravity.pulse.Low();
} }
break;
} }
case PARAM_MAIN_ENCODER_DIR: case PARAM_MAIN_ENCODER_DIR:
updateSelection(app.selected_sub_param, val, 2); updateSelection(app.selected_sub_param, val, 2);
break; break;
case PARAM_MAIN_SAVE_DATA:
case PARAM_MAIN_LOAD_DATA:
updateSelection(app.selected_sub_param, val, MAX_SAVE_SLOTS + 1);
break;
case PARAM_MAIN_RESET_STATE: case PARAM_MAIN_RESET_STATE:
updateSelection(app.selected_sub_param, val, 2); updateSelection(app.selected_sub_param, val, 2);
break; break;

View File

@ -12,9 +12,10 @@ struct AppState {
bool refresh_screen = true; bool refresh_screen = true;
bool editing_param = false; bool editing_param = false;
byte selected_param = 0; byte selected_param = 0;
byte selected_sub_param = 0; 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_shuffle = 0; byte selected_swing = 0;
byte selected_save_slot = 0; // The currently active save slot.
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;
Channel channel[Gravity::OUTPUT_COUNT]; Channel channel[Gravity::OUTPUT_COUNT];
@ -31,6 +32,8 @@ enum ParamsMainPage : uint8_t {
PARAM_MAIN_SOURCE, PARAM_MAIN_SOURCE,
PARAM_MAIN_PULSE, PARAM_MAIN_PULSE,
PARAM_MAIN_ENCODER_DIR, PARAM_MAIN_ENCODER_DIR,
PARAM_MAIN_SAVE_DATA,
PARAM_MAIN_LOAD_DATA,
PARAM_MAIN_RESET_STATE, PARAM_MAIN_RESET_STATE,
PARAM_MAIN_LAST, PARAM_MAIN_LAST,
}; };

View File

@ -24,23 +24,23 @@ static const byte MOD_CHOICE_SIZE = 25;
// Negative numbers are multipliers, positive are divisors. // Negative numbers are multipliers, positive are divisors.
static const int CLOCK_MOD[MOD_CHOICE_SIZE] PROGMEM = { static const int CLOCK_MOD[MOD_CHOICE_SIZE] PROGMEM = {
// Multipliers // Multipliers
-24, -16, -12, -8, -6, -4, -3, -2, -24, -16, -12, -8, -6, -4, -3, -2,
// Internal Clock Unity // Internal Clock Unity
1, 1,
// Divisors // Divisors
2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 24, 32, 64, 128 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 24, 32, 64, 128};
};
// This represents the number of clock pulses for a 96 PPQN clock source // This represents the number of clock pulses for a 96 PPQN clock source
// 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 = {
// Multiplier Pulses (96 / X) // Multiplier Pulses (96 / X)
4, 6, 8, 12, 16, 24, 32, 48, 4, 6, 8, 12, 16, 24, 32, 48,
// Internal Clock Pulses // Internal Clock Pulses
96, 96,
// Divisor Pulses (96 * X) // Divisor Pulses (96 * X)
192, 288, 384, 480, 576, 672, 768, 864, 960, 1056, 1152, 1536, 2304, 3072, 6144, 12288 192, 288, 384, 480, 576, 672, 768, 864, 960, 1056, 1152, 1536, 2304, 3072, 6144, 12288};
};
static const byte DEFAULT_CLOCK_MOD_INDEX = 8; // x1 or 96 PPQN.
class Channel { class Channel {
public: public:
@ -50,7 +50,7 @@ class Channel {
void Init() { void Init() {
// Reset base values to their defaults // Reset base values to their defaults
base_clock_mod_index = 7; base_clock_mod_index = DEFAULT_CLOCK_MOD_INDEX;
base_probability = 100; base_probability = 100;
base_duty_cycle = 50; base_duty_cycle = 50;
base_offset = 0; base_offset = 0;
@ -64,6 +64,9 @@ class Channel {
cvmod_offset = base_offset; cvmod_offset = base_offset;
cvmod_swing = base_swing; cvmod_swing = base_swing;
cv1_dest = CV_DEST_NONE;
cv2_dest = CV_DEST_NONE;
pattern.Init(DEFAULT_PATTERN); pattern.Init(DEFAULT_PATTERN);
// Calcule the clock mod pulses on init. // Calcule the clock mod pulses on init.
@ -211,7 +214,7 @@ class Channel {
return; return;
} }
int dest_mod = _calculateMod(CV_DEST_MOD, cv1_val, cv2_val, -(MOD_CHOICE_SIZE/2), MOD_CHOICE_SIZE/2); 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); cvmod_clock_mod_index = constrain(base_clock_mod_index + dest_mod, 0, 100);
int prob_mod = _calculateMod(CV_DEST_PROB, cv1_val, cv2_val, -50, 50); int prob_mod = _calculateMod(CV_DEST_PROB, cv1_val, cv2_val, -50, 50);

View File

@ -4,6 +4,7 @@
#include <Arduino.h> #include <Arduino.h>
#include "app_state.h" #include "app_state.h"
#include "save_state.h"
// //
// UI Display functions for drawing the UI to the OLED display. // UI Display functions for drawing the UI to the OLED display.
@ -33,32 +34,33 @@ const uint8_t TEXT_FONT[437] U8G2_FONT_SECTION("velvetscreen") PROGMEM =
/* /*
* Font: STK-L.bdf 36pt * Font: STK-L.bdf 36pt
* https://stncrn.github.io/u8g2-unifont-helper/ * https://stncrn.github.io/u8g2-unifont-helper/
* "%/0123456789ACDEFINORSTUVXx" * "%/0123456789ABCDEFILNORSTUVXx"
*/ */
const uint8_t LARGE_FONT[715] U8G2_FONT_SECTION("stk-l") = const uint8_t LARGE_FONT[766] U8G2_FONT_SECTION("stk-l") =
"\33\0\4\4\4\5\2\1\6\20\30\0\0\27\0\0\0\1\77\0\0\2\256%'\17\37\313\330R#&" "\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"
"\32!F\14\211I\310\24!\65\204(MF\21)Cd\304\10\62b\14\215\60Vb\334\20\0/\14" "\64B\214\30\22\223\220)Bj\10Q\232\214\42R\206\310\210\21d\304\30\32a\254\304\270!\0/\14"
"\272\336\336d\244\350\263q\343\0\60\37|\377\216!%*\10\35\263\253ChD\30\21bB\14\242S" "\272\272\275\311H\321g\343\306\1\60\37|\373\35CJT\20:fW\207\320\210\60\42\304\204\30D\247"
"\306lv\210\204\22Ef\0\61\24z\337\322\60R\205\314\234\31\61F\310\270\371\177\224\42\3\62\33|" "\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|"
"\377\216)\64*\10\35\63\66r\206\304\314`c\252\34\301\221\263|\360\300\0\63\34|\377\216)\64*" "\373\35ShT\20:fl\344\14\211\231\301\306T\71\202#g\371\340\201\1\63\34|\373\35ShT"
"\10\35\63\66r \71\332YIr\226\306\16\221P\203\312\14\0\64 |\377\226\220AC\306\20\31B" "\20:fl\344@r\264\263\222\344,\215\35\42\241\6\225\31\0\64 |\373-!\203\206\214!\62\204"
"f\310\240\21\204F\214\32\61j\304(cv\366\200\305\312\371\0\65\32|\377\206\212-F\316\27\204\224" "\314\220A#\10\215\30\65b\324\210Q\306\354\354\1\213\225\363\1\65\32|\373\15\25[\214\234/\10)"
"\254\30\65t\344,\215\35\42\241\6\225\31\0\66\33}\17\317\251\64+\206\235\63:/\314,aA\352" "Y\61j\350\310Y\32;DB\15*\63\0\66\33}\33\236SiV\14;gt^\230Y\302\202\324"
"\234\335\235\42\261&\325\31\0\67\23|\377\302\212\7)\347Crt\70\345\300\221\363\16\0\70 |\377" "\71\273;EbM\252\63\0\67\23|\373\205\25\17R\316\207\344\350p\312\201#\347\35\0\70 |\373"
"\216)\64*\10\35\263\354\20\11\42d\20\235BC\204\4\241cvv\210\204\32Tf\0\71\32|\377" "\35ShT\20:f\331!\22D\310 :\205\206\10\11B\307\354\354\20\11\65\250\314\0\71\32|\373"
"\216)\64*\10\35\263\263C$\226\250I\71_\14\42\241\6\225\31\0A\26}\17S\271Si(\31" "\35ShT\20:fg\207H,Q\223r\276\30DB\15*\63\0A\26}\33\246r\247\322P\62"
"\65d\324\210q\366\356\301w\366\273\1C\27}\17\317\251\64K\10!\63:\377\247\304F\20\42\261F" "j\310\250\21\343\354\335\203\357\354w\3B$}\33\206Dj\226\214\42\61l\304\260\21\303F\14\33\61"
"\21\22\0D\33}\17C\42\65KF\15\31\66b\330\210q\366\77;\66b\24\211%j\22\1E\21" "\212\304\222MF\221\30v\316\236=\10\301b\11\0C\27}\33\236Si\226\20Bft\376O\211\215"
"|\377\302\7)\347%\42\214F\316/\37<\60F\20|\377\302\7)\347\313\64\331\214\234\177\11\0I" " Db\215\42$\0D\33}\33\206Dj\226\214\32\62l\304\260\21\343\354\177vl\304(\22K\324"
"\7so\302\37$N#}\17\203@s\346\216\35C\205*Q\42\23cL\214\61\62\304\310\20\63#" "$\2E\22|\373\205\17R\316KD\30\215\234_>x`\0F\20|\373\205\17R\316\227i\262\31"
"\314\214\60\224\25f\327\231\33O\26}\17\317\251\64KF\215\30g\377\337\215\30\65dM\252\63\0R" "\71\377\22\0I\7s\333\204\77HL\15{\333\205\201\363\377\77|\360`\0N$}\33\6\201\346\314"
"\61\216\37\203\242\65L\206\221\30\67b\334\210q#\306\215\30\67b\30\211QD\230(J\65d\330\230" "\35;\206\12U\242D&\306\230\30cd\210\221!fF\230\31a(+\314\256\63\67\0O\26}\33"
"Qc\10\315j\314(\42\303H\214\33\61\356\340\0S\42\216\37\317\261DKH\221\30\67b\334\210\261" "\236Si\226\214\32\61\316\376\277\33\61j\310\232Tg\0R\61\216;\6Ek\230\14#\61n\304\270"
"c)M\226-\331\301c\307\32\64\207\212D\223Uh\0T\15}\17\303\7\251\206\316\377\377\12\0U" "\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"
"\21|\377\302\60\373\377\317F\14\32\242\6\225\31\0V\26\177\375\302H\373\377\345\210qCH\221\241\212" "\334\301\1S\42\216;\236c\211\226\220\42\61n\304\270\21c\307R\232,[\262\203\307\216\65h\16\25"
"\4\271\223e\207\1X)~\37\303@\203\307H\14\33B\210\14\21RC\206\241\63h\222(I\203\346" "\21&\253\320\0T\15}\33\206\17R\15\235\377\377\25\0U\21|\373\205a\366\377\237\215\30\64D\15"
"\220\15\31E\204\14!\42\303F\20;h\341\0x\24\312\336\302 CGH\240\61E\312\14\222)\6" "*\63\0V\26\177\371\205\221\366\377\313\21\343\206\220\42C\25\11r'\313\16\3X)~;\206\201\6"
"Y\64\0\0\0\0\4\377\377\0"; "\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
@ -151,6 +153,10 @@ void drawMenuItems(String menu_items[], int menu_size) {
} }
} }
// 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); }
// 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();
@ -158,17 +164,25 @@ void swingDivisionMark() {
case 58: // 1/32nd case 58: // 1/32nd
case 66: // 1/16th case 66: // 1/16th
case 75: // 1/8th case 75: // 1/8th
gravity.display.drawBox(56, 4, 4, 4); 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
gravity.display.drawBox(56, 4, 4, 4); hollowTick();
gravity.display.drawBox(57, 5, 2, 2);
break; break;
} }
} }
// Human friendly display value for save slot.
String displaySaveSlot(int slot) {
if (slot >= 0 && slot < MAX_SAVE_SLOTS / 2) {
return String("A") + String(slot + 1);
} else if (slot >= MAX_SAVE_SLOTS / 2 && slot <= MAX_SAVE_SLOTS) {
return String("B") + String(slot - (MAX_SAVE_SLOTS / 2) + 1);
}
}
// Main display functions // Main display functions
void DisplayMainPage() { void DisplayMainPage() {
@ -229,17 +243,37 @@ void DisplayMainPage() {
mainText = F("DIR"); mainText = F("DIR");
subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("REVERSED"); subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("REVERSED");
break; break;
case PARAM_MAIN_RESET_STATE: case PARAM_MAIN_SAVE_DATA:
mainText = F("RST"); case PARAM_MAIN_LOAD_DATA:
subText = app.selected_sub_param == 0 ? F("RESET ALL") : F("BACK"); if (app.selected_sub_param == 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; 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");
}
} }
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("PULSE OUT"), F("ENCODER DIR"), F("RESET")}; String menu_items[PARAM_MAIN_LAST] = {F("TEMPO"), F("SOURCE"), F("PULSE OUT"), F("ENCODER DIR"), F("SAVE"), F("LOAD"), F("RESET")};
drawMenuItems(menu_items, PARAM_MAIN_LAST); drawMenuItems(menu_items, PARAM_MAIN_LAST);
} }

View File

@ -66,7 +66,7 @@ class Pattern {
// Update the euclidean rhythm pattern using bitmap // Update the euclidean rhythm pattern using bitmap
void updatePattern() { void updatePattern() {
pattern_bitmap_ = 0; // Clear the bitmap pattern_bitmap_ = 0; // Clear the bitmap
if (steps_ == 0) return; if (steps_ == 0) return;

View File

@ -4,49 +4,50 @@
#include "app_state.h" #include "app_state.h"
// Calculate the starting address for EepromData, leaving space for metadata.
static const int 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()) { if (_isDataValid()) {
static EepromData load_data; // Load data from the transient slot.
EEPROM.get(sizeof(Metadata), load_data); return loadData(app, MAX_SAVE_SLOTS);
// Restore main app state
app.tempo = load_data.tempo;
app.encoder_reversed = load_data.encoder_reversed;
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);
// Loop through and restore each channel's state.
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.setProbability(saved_ch_state.base_probability);
ch.setDutyCycle(saved_ch_state.base_duty_cycle);
ch.setOffset(saved_ch_state.base_offset);
ch.setSwing(saved_ch_state.base_shuffle);
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.setCv1Dest(static_cast<CvDestination>(saved_ch_state.cv2_dest));
}
return true;
} else { } else {
// EEPROM does not contain save data for this firmware & version.
// Initialize eeprom and save default patter to all save slots.
reset(app); reset(app);
_saveMetadata();
// MAX_SAVE_SLOTS slot is reserved for transient state.
for (int i = 0; i <= MAX_SAVE_SLOTS; i++) {
app.selected_save_slot = i;
_saveState(app, i);
}
return false; return false;
} }
} }
void StateManager::_save(const AppState& app) { bool StateManager::loadData(AppState& app, byte slot_index) {
// Ensure interrupts do not cause corrupt data writes. if (slot_index >= MAX_SAVE_SLOTS) return false;
noInterrupts();
_saveState(app); _loadState(app, slot_index);
interrupts();
return true;
}
void StateManager::saveData(const AppState& app) {
if (app.selected_save_slot >= MAX_SAVE_SLOTS) return;
_saveState(app, app.selected_save_slot);
_isDirty = false;
}
void StateManager::update(const AppState& app) {
if (_isDirty && (millis() - _lastChangeTime > SAVE_DELAY_MS)) {
// MAX_SAVE_SLOTS slot is reserved for transient state.
_saveState(app, MAX_SAVE_SLOTS);
_isDirty = false;
}
} }
void StateManager::reset(AppState& app) { void StateManager::reset(AppState& app) {
@ -56,27 +57,15 @@ void StateManager::reset(AppState& app) {
app.selected_channel = 0; app.selected_channel = 0;
app.selected_source = Clock::SOURCE_INTERNAL; app.selected_source = Clock::SOURCE_INTERNAL;
app.selected_pulse = Clock::PULSE_PPQN_24; app.selected_pulse = Clock::PULSE_PPQN_24;
app.selected_save_slot = 0;
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();
} }
noInterrupts();
_saveMetadata(); // Write the new metadata
_saveState(app); // Write the new (default) app state
interrupts();
_isDirty = false; _isDirty = false;
} }
void StateManager::update(const AppState& app) {
// Check if a save is pending and if enough time has passed.
if (_isDirty && (millis() - _lastChangeTime > SAVE_DELAY_MS)) {
_save(app);
_isDirty = false; // Clear the flag, we are now "clean".
}
}
void StateManager::markDirty() { void StateManager::markDirty() {
_isDirty = true; _isDirty = true;
_lastChangeTime = millis(); _lastChangeTime = millis();
@ -90,39 +79,76 @@ bool StateManager::_isDataValid() {
return name_match && version_match; return name_match && version_match;
} }
void StateManager::_saveState(const AppState& app) { void StateManager::_saveState(const AppState& app, byte slot_index) {
if (app.selected_save_slot >= MAX_SAVE_SLOTS) return;
noInterrupts();
static EepromData save_data; static EepromData save_data;
// Populate main app state
save_data.tempo = app.tempo; save_data.tempo = app.tempo;
save_data.encoder_reversed = app.encoder_reversed; save_data.encoder_reversed = app.encoder_reversed;
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.selected_save_slot = app.selected_save_slot;
// Loop through and populate each channel's state
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];
// Use the getters with 'withCvMod = false' to get the base values
save_ch.base_clock_mod_index = ch.getClockModIndex(false); save_ch.base_clock_mod_index = ch.getClockModIndex(false);
save_ch.base_probability = ch.getProbability(false); save_ch.base_probability = ch.getProbability(false);
save_ch.base_duty_cycle = ch.getDutyCycle(false); save_ch.base_duty_cycle = ch.getDutyCycle(false);
save_ch.base_offset = ch.getOffset(false); save_ch.base_offset = ch.getOffset(false);
save_ch.base_shuffle = ch.getSwing(); save_ch.base_swing = ch.getSwing(false);
save_ch.base_euc_steps = ch.getSteps(); save_ch.base_euc_steps = ch.getSteps(false);
save_ch.base_euc_hits = ch.getHits(); save_ch.base_euc_hits = ch.getHits(false);
save_ch.cv1_dest = static_cast<byte>(ch.getCv1Dest()); save_ch.cv1_dest = static_cast<byte>(ch.getCv1Dest());
save_ch.cv2_dest = static_cast<byte>(ch.getCv2Dest()); save_ch.cv2_dest = static_cast<byte>(ch.getCv2Dest());
} }
EEPROM.put(sizeof(Metadata), save_data);
int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData));
EEPROM.put(address, save_data);
interrupts();
}
void StateManager::_loadState(AppState& app, byte slot_index) {
noInterrupts();
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.encoder_reversed = load_data.encoder_reversed;
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.selected_save_slot = slot_index;
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.setProbability(saved_ch_state.base_probability);
ch.setDutyCycle(saved_ch_state.base_duty_cycle);
ch.setOffset(saved_ch_state.base_offset);
ch.setSwing(saved_ch_state.base_swing);
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));
}
interrupts();
} }
void StateManager::_saveMetadata() { void StateManager::_saveMetadata() {
noInterrupts();
Metadata current_meta; Metadata current_meta;
strcpy(current_meta.sketch_name, SKETCH_NAME); strcpy(current_meta.sketch_name, SKETCH_NAME);
current_meta.version = SKETCH_VERSION; current_meta.version = SKETCH_VERSION;
EEPROM.put(0, current_meta); EEPROM.put(0, current_meta);
interrupts();
} }

View File

@ -9,13 +9,21 @@ struct AppState;
// Define the constants for the current firmware. // Define the constants for the current firmware.
const char SKETCH_NAME[] = "Gravity"; const char SKETCH_NAME[] = "Gravity";
const byte SKETCH_VERSION = 6; const byte SKETCH_VERSION = 7;
// Number of available save slots.
const byte MAX_SAVE_SLOTS = 10;
// Define the minimum amount of time between EEPROM writes. // Define the minimum amount of time between EEPROM writes.
static const unsigned long SAVE_DELAY_MS = 2000; static const unsigned long SAVE_DELAY_MS = 2000;
/** /**
* @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 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 { class StateManager {
public: public:
@ -23,6 +31,10 @@ class 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.
bool loadData(AppState& app, byte slot_index);
// Save data to specified slot.
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.
@ -30,18 +42,17 @@ class StateManager {
// Indicate that state has changed and we should save. // Indicate that state has changed and we should save.
void markDirty(); void markDirty();
private:
// 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];
byte version; byte version;
char sketch_name[16];
}; };
struct ChannelState { struct ChannelState {
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_shuffle; byte base_swing;
byte base_euc_steps; byte base_euc_steps;
byte base_euc_hits; byte base_euc_hits;
byte cv1_dest; // Cast the CvDestination enum as a byte for storage byte cv1_dest; // Cast the CvDestination enum as a byte for storage
@ -55,13 +66,15 @@ class StateManager {
byte selected_channel; byte selected_channel;
byte selected_source; byte selected_source;
byte selected_pulse; byte selected_pulse;
byte selected_save_slot;
ChannelState channel_data[Gravity::OUTPUT_COUNT]; ChannelState channel_data[Gravity::OUTPUT_COUNT];
}; };
void _save(const AppState& app); private:
bool _isDataValid(); bool _isDataValid();
void _saveState(const AppState& app);
void _saveMetadata(); void _saveMetadata();
void _saveState(const AppState& app, byte slot_index);
void _loadState(AppState& app, byte slot_index);
bool _isDirty; bool _isDirty;
unsigned long _lastChangeTime; unsigned long _lastChangeTime;