diff --git a/.gitignore b/.gitignore index 41cc7c8..af53804 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ docs .vscode -.DS_Store \ No newline at end of file +.DS_Store +build/* \ No newline at end of file diff --git a/README.md b/README.md index f244487..569aeed 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,8 @@ void UpdateDisplay() { } ``` +### Build for release + +``` +$ arduino-cli compile -v -b arduino:avr:nano ./firmware/Gravity/Gravity.ino -e --output-dir=./build/ +``` \ No newline at end of file diff --git a/examples/clock_mod/clock_mod.ino b/examples/clock_mod/clock_mod.ino index e9458ea..cba7091 100644 --- a/examples/clock_mod/clock_mod.ino +++ b/examples/clock_mod/clock_mod.ino @@ -17,7 +17,7 @@ * */ -#include "gravity.h" +#include // Firmware state variables. struct Channel { @@ -33,8 +33,8 @@ struct AppState { bool editing_param = false; int selected_param = 0; byte selected_channel = 0; // 0=tempo, 1-6=output channel - Source selected_source = SOURCE_INTERNAL; - Channel channel[OUTPUT_COUNT]; + Clock::Source selected_source = Clock::SOURCE_INTERNAL; + Channel channel[Gravity::OUTPUT_COUNT]; }; AppState app; @@ -123,7 +123,7 @@ void loop() { // void HandleIntClockTick(uint32_t tick) { - for (int i = 0; i < OUTPUT_COUNT; i++) { + for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { auto& channel = app.channel[i]; auto& output = gravity.outputs[i]; @@ -178,7 +178,7 @@ void HandleEncoderPressed() { app.refresh_screen = true; } -void HandleRotate(Direction dir, int val) { +void HandleRotate(int val) { if (!app.editing_param) { // Navigation Mode const int max_param = (app.selected_channel == 0) ? PARAM_MAIN_LAST : PARAM_CH_LAST; @@ -188,17 +188,17 @@ void HandleRotate(Direction dir, int val) { if (app.selected_channel == 0) { editMainParameter(val); } else { - editChannelParameter(dir, val); + editChannelParameter(val); } } app.refresh_screen = true; } -void HandlePressedRotate(Direction dir, int val) { - if (dir == DIRECTION_INCREMENT && app.selected_channel < OUTPUT_COUNT) { +void HandlePressedRotate(int val) { + if (val > 0 && app.selected_channel < Gravity::OUTPUT_COUNT) { app.selected_channel++; - } else if (dir == DIRECTION_DECREMENT && app.selected_channel > 0) { + } else if (val < 0 && app.selected_channel > 0) { app.selected_channel--; } app.selected_param = 0; @@ -216,21 +216,21 @@ void editMainParameter(int val) { case PARAM_MAIN_SOURCE: { int source = static_cast(app.selected_source); - updateSelection(source, val, SOURCE_LAST); - app.selected_source = static_cast(source); + updateSelection(source, val, Clock::SOURCE_LAST); + app.selected_source = static_cast(source); gravity.clock.SetSource(app.selected_source); break; } } } -void editChannelParameter(Direction dir, int val) { +void editChannelParameter(int val) { auto& ch = GetSelectedChannel(); switch (static_cast(app.selected_param)) { case PARAM_CH_MOD: - if (dir == DIRECTION_INCREMENT && ch.clock_mod_index < MOD_CHOICE_SIZE - 1) { + if (val > 0 && ch.clock_mod_index < MOD_CHOICE_SIZE - 1) { ch.clock_mod_index++; - } else if (dir == DIRECTION_DECREMENT && ch.clock_mod_index > 0) { + } else if (val < 0 && ch.clock_mod_index > 0) { ch.clock_mod_index--; } break; @@ -265,7 +265,7 @@ Channel& GetSelectedChannel() { } void ResetOutputs() { - for (int i = 0; i < OUTPUT_COUNT; i++) { + for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { gravity.outputs[i].Low(); } } @@ -311,7 +311,7 @@ void DisplayMainPage() { if (app.selected_param == 0) { // Serial MIID is too unstable to display bpm in real time. - if (app.selected_source == SOURCE_EXTERNAL_MIDI) { + if (app.selected_source == Clock::SOURCE_EXTERNAL_MIDI) { sprintf(mainText, "%s", "EXT"); } else { sprintf(mainText, "%d", gravity.clock.Tempo()); @@ -319,19 +319,19 @@ void DisplayMainPage() { subText = "BPM"; } else if (app.selected_param == 1) { switch (app.selected_source) { - case SOURCE_INTERNAL: + case Clock::SOURCE_INTERNAL: sprintf(mainText, "%s", "INT"); subText = "Clock"; break; - case SOURCE_EXTERNAL_PPQN_24: + case Clock::SOURCE_EXTERNAL_PPQN_24: sprintf(mainText, "%s", "EXT"); subText = "24 PPQN"; break; - case SOURCE_EXTERNAL_PPQN_4: + case Clock::SOURCE_EXTERNAL_PPQN_4: sprintf(mainText, "%s", "EXT"); subText = "4 PPQN"; break; - case SOURCE_EXTERNAL_MIDI: + case Clock::SOURCE_EXTERNAL_MIDI: sprintf(mainText, "%s", "EXT"); subText = "MIDI"; break; @@ -399,7 +399,7 @@ void DisplaySelectedChannel() { gravity.display.drawHLine(1, boxY, SCREEN_WIDTH - 2); gravity.display.drawVLine(SCREEN_WIDTH - 2, boxY, boxHeight); - for (int i = 0; i < OUTPUT_COUNT + 1; i++) { + 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) diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index 34de653..ebc0fe3 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -2,7 +2,7 @@ * @file Gravity.ino * @author Adam Wonak (https://github.com/awonak/) * @brief Alt firmware version of Gravity by Sitka Instruments. - * @version v2.0.1 - June 2025 awonak - Full rewrite + * @version v2.0.0 - June 2025 awonak - Full rewrite * @version v1.0 - August 2023 Oleksiy H - Initial release * @date 2025-07-04 * @@ -25,7 +25,7 @@ * 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. @@ -33,20 +33,22 @@ * BTN1: * Play/pause - start or stop the internal clock. * - * BTN2: + * BTN2: * Shift - hold and rotate encoder to change current selected output channel. * * EXT: - * External clock input. When Gravity is set to INTERNAL clock mode, this - * input is used to reset clocks. - * + * External clock input. When Gravity is set to INTERNAL or MIDI clock + * source, this input is used to reset clocks. + * * CV1: - * CV2: * External analog input used to provide modulation to any channel parameter. * + * CV2: + * External analog input used to provide modulation to any channel parameter. + * */ -#include +#include #include "app_state.h" #include "channel.h" @@ -64,6 +66,10 @@ 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); @@ -125,23 +131,22 @@ void HandleIntClockTick(uint32_t tick) { int clock_index; switch (app.selected_pulse) { case Clock::PULSE_PPQN_24: - clock_index = 0; + clock_index = PULSE_PPQN_24_CLOCK_MOD_INDEX; break; case Clock::PULSE_PPQN_4: - clock_index = 4; + clock_index = PULSE_PPQN_4_CLOCK_MOD_INDEX; break; case Clock::PULSE_PPQN_1: - clock_index = 7; + clock_index = PULSE_PPQN_1_CLOCK_MOD_INDEX; break; } - const uint32_t pulse_high_ticks = CLOCK_MOD_PULSES[clock_index]; + 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(); - } - if (pulse_low_ticks % pulse_high_ticks == 0) { + } else if (pulse_low_ticks % pulse_high_ticks == 0) { gravity.pulse.Low(); } } @@ -152,13 +157,16 @@ void HandleIntClockTick(uint32_t tick) { } void HandleExtClockTick() { - if (gravity.clock.InternalSource()) { - // Use EXT as Reset when internally clocked. - ResetOutputs(); - gravity.clock.Reset(); - } else { - // Register clock tick. - gravity.clock.Tick(); + 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; } @@ -168,6 +176,21 @@ void HandleExtClockTick() { // 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(); @@ -181,17 +204,17 @@ void HandleEncoderPressed() { if (app.selected_channel == 0) { // main page // TODO: rewrite as switch if (app.selected_param == PARAM_MAIN_ENCODER_DIR) { - bool reversed = app.selected_sub_param == 1; - gravity.encoder.SetReverseDirection(reversed); + app.encoder_reversed = app.selected_sub_param == 1; + gravity.encoder.SetReverseDirection(app.encoder_reversed); } if (app.selected_param == PARAM_MAIN_SAVE_DATA) { - if (app.selected_sub_param < MAX_SAVE_SLOTS) { + 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 < MAX_SAVE_SLOTS) { + if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) { app.selected_save_slot = app.selected_sub_param; stateManager.loadData(app, app.selected_save_slot); InitGravity(app); @@ -203,6 +226,14 @@ void HandleEncoderPressed() { 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(); @@ -272,11 +303,14 @@ void editMainParameter(int val) { break; case PARAM_MAIN_SAVE_DATA: case PARAM_MAIN_LOAD_DATA: - updateSelection(app.selected_sub_param, val, MAX_SAVE_SLOTS + 1); + 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; } } diff --git a/firmware/Gravity/app_state.h b/firmware/Gravity/app_state.h index e7d9ab5..90712df 100644 --- a/firmware/Gravity/app_state.h +++ b/firmware/Gravity/app_state.h @@ -12,16 +12,14 @@ #ifndef APP_STATE_H #define APP_STATE_H -#include +#include #include "channel.h" // Global state for settings and app behavior. struct AppState { int tempo = Clock::DEFAULT_TEMPO; - bool encoder_reversed = false; - bool refresh_screen = true; - bool editing_param = false; + 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 @@ -29,7 +27,9 @@ struct AppState { 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; - Channel channel[Gravity::OUTPUT_COUNT]; + bool editing_param = false; + bool encoder_reversed = false; + bool refresh_screen = true; }; extern AppState app; @@ -38,28 +38,4 @@ static Channel& GetSelectedChannel() { return app.channel[app.selected_channel - 1]; } -enum ParamsMainPage : uint8_t { - PARAM_MAIN_TEMPO, - PARAM_MAIN_SOURCE, - PARAM_MAIN_PULSE, - PARAM_MAIN_ENCODER_DIR, - PARAM_MAIN_SAVE_DATA, - PARAM_MAIN_LOAD_DATA, - PARAM_MAIN_RESET_STATE, - PARAM_MAIN_LAST, -}; - -enum ParamsChannelPage : uint8_t { - PARAM_CH_MOD, - PARAM_CH_PROB, - PARAM_CH_DUTY, - PARAM_CH_OFFSET, - PARAM_CH_SWING, - PARAM_CH_EUC_STEPS, - PARAM_CH_EUC_HITS, - PARAM_CH_CV1_DEST, - PARAM_CH_CV2_DEST, - PARAM_CH_LAST, -}; - #endif // APP_STATE_H \ No newline at end of file diff --git a/firmware/Gravity/channel.h b/firmware/Gravity/channel.h index 6f46b28..df88785 100644 --- a/firmware/Gravity/channel.h +++ b/firmware/Gravity/channel.h @@ -13,7 +13,7 @@ #define CHANNEL_H #include -#include +#include #include "euclidean.h" @@ -34,24 +34,28 @@ static const byte MOD_CHOICE_SIZE = 25; // Negative numbers are multipliers, positive are divisors. static const int CLOCK_MOD[MOD_CHOICE_SIZE] PROGMEM = { - // Multipliers - -24, -16, -12, -8, -6, -4, -3, -2, - // Internal Clock Unity - 1, // Divisors - 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 24, 32, 64, 128}; + 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 = { - // Multiplier Pulses (96 / X) - 4, 6, 8, 12, 16, 24, 32, 48, + // Divisor Pulses (96 * X) + 12288, 6144, 3072, 2304, 1536, 1152, 1056, 960, 864, 768, 672, 576, 480, 384, 288, 192, // Internal Clock Pulses 96, - // Divisor Pulses (96 * X) - 192, 288, 384, 480, 576, 672, 768, 864, 960, 1056, 1152, 1536, 2304, 3072, 6144, 12288}; + // Multiplier Pulses (96 / X) + 48, 32, 24, 16, 12, 8, 6, 4}; -static const byte DEFAULT_CLOCK_MOD_INDEX = 8; // 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_4_CLOCK_MOD_INDEX = MOD_CHOICE_SIZE - 6; +static const byte PULSE_PPQN_1_CLOCK_MOD_INDEX = MOD_CHOICE_SIZE - 9; class Channel { public: @@ -157,6 +161,8 @@ class Channel { 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. @@ -164,6 +170,12 @@ class Channel { * @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]); // Conditionally apply swing on down beats. @@ -243,7 +255,7 @@ class Channel { 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, MAX_PATTERN_LEN); + 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. @@ -294,6 +306,9 @@ class Channel { // Euclidean pattern Pattern pattern; + // Mute channel flag + bool mute; + // Pre-calculated pulse values for ISR performance uint16_t _duty_pulses; uint16_t _offset_pulses; diff --git a/firmware/Gravity/display.h b/firmware/Gravity/display.h index cf37631..37b900b 100644 --- a/firmware/Gravity/display.h +++ b/firmware/Gravity/display.h @@ -96,6 +96,33 @@ 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_SOURCE, + PARAM_MAIN_PULSE, + PARAM_MAIN_ENCODER_DIR, + 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_PROB, + PARAM_CH_DUTY, + PARAM_CH_OFFSET, + PARAM_CH_SWING, + 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); @@ -187,10 +214,10 @@ void swingDivisionMark() { // Human friendly display value for save slot. String displaySaveSlot(int slot) { - if (slot >= 0 && slot < MAX_SAVE_SLOTS / 2) { + if (slot >= 0 && slot < StateManager::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); + } else if (slot >= StateManager::MAX_SAVE_SLOTS / 2 && slot <= StateManager::MAX_SAVE_SLOTS) { + return String("B") + String(slot - (StateManager::MAX_SAVE_SLOTS / 2) + 1); } } @@ -256,7 +283,7 @@ void DisplayMainPage() { break; case PARAM_MAIN_SAVE_DATA: case PARAM_MAIN_LOAD_DATA: - if (app.selected_sub_param == MAX_SAVE_SLOTS) { + if (app.selected_sub_param == StateManager::MAX_SAVE_SLOTS) { mainText = F("x"); subText = F("BACK TO MAIN"); } else { @@ -278,13 +305,23 @@ void DisplayMainPage() { 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("SOURCE"), F("PULSE OUT"), F("ENCODER DIR"), F("SAVE"), F("LOAD"), F("RESET")}; + String menu_items[PARAM_MAIN_LAST] = {F("TEMPO"), F("SOURCE"), F("PULSE OUT"), F("ENCODER DIR"), F("SAVE"), F("LOAD"), F("RESET"), F("ERASE")}; drawMenuItems(menu_items, PARAM_MAIN_LAST); } @@ -428,7 +465,25 @@ void UpdateDisplay() { DisplayChannelPage(); } // Global channel select UI. - DisplaySelectedChannel(); + 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(16 + (textWidth / 2), 20, 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()); } diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index 052779b..e701c4f 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -15,65 +15,86 @@ #include "app_state.h" +// Define the constants for the current firmware. +const char StateManager::SKETCH_NAME[] = "ALT GRAVITY"; +const char StateManager::SEMANTIC_VERSION[] = "V2.0.0BETA3"; // 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. -static const int EEPROM_DATA_START_ADDR = sizeof(StateManager::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) { if (_isDataValid()) { - // Load data from the transient slot. - return loadData(app, MAX_SAVE_SLOTS); - } else { - // EEPROM does not contain save data for this firmware & version. - // Initialize eeprom and save default patter to all save slots. - 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); - } + // Load global settings. + _loadMetadata(app); + // Load app data from the transient slot. + _loadState(app, TRANSIENT_SLOT); + return 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); return false; } } bool StateManager::loadData(AppState& app, byte slot_index) { - if (slot_index >= MAX_SAVE_SLOTS) return false; + // Check if slot_index is within max range + 1 for transient. + 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. _loadState(app, slot_index); + app.selected_save_slot = slot_index; + // Persist this change in the global metadata. + _saveMetadata(app); return true; } +// Save app state to user specified save slot. void StateManager::saveData(const AppState& app) { - if (app.selected_save_slot >= MAX_SAVE_SLOTS) return; + // Check if slot_index is within max range + 1 for transient. + if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) return; _saveState(app, app.selected_save_slot); + _saveMetadata(app); _isDirty = false; } +// 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)) { - // MAX_SAVE_SLOTS slot is reserved for transient state. - _saveState(app, MAX_SAVE_SLOTS); + _saveState(app, TRANSIENT_SLOT); + _saveMetadata(app); _isDirty = false; } } void StateManager::reset(AppState& app) { - app.tempo = Clock::DEFAULT_TEMPO; - app.encoder_reversed = false; - app.selected_param = 0; - app.selected_channel = 0; - app.selected_source = Clock::SOURCE_INTERNAL; - app.selected_pulse = Clock::PULSE_PPQN_24; - app.selected_save_slot = 0; + 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; for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { app.channel[i].Init(); } + // Load global settings from Metadata + _loadMetadata(app); + _isDirty = false; } @@ -82,28 +103,48 @@ void StateManager::markDirty() { _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 load_meta; - EEPROM.get(0, load_meta); - bool name_match = (strcmp(load_meta.sketch_name, SKETCH_NAME) == 0); - bool version_match = (load_meta.version == SKETCH_VERSION); + 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) { - if (app.selected_save_slot >= MAX_SAVE_SLOTS) return; + // Check if slot_index is within max range + 1 for transient. + if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) return; noInterrupts(); static EepromData save_data; save_data.tempo = app.tempo; - save_data.encoder_reversed = app.encoder_reversed; save_data.selected_param = app.selected_param; save_data.selected_channel = app.selected_channel; save_data.selected_source = static_cast(app.selected_source); save_data.selected_pulse = static_cast(app.selected_pulse); - save_data.selected_save_slot = app.selected_save_slot; + // 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]; @@ -124,6 +165,9 @@ void StateManager::_saveState(const AppState& app, byte slot_index) { } 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; + noInterrupts(); static EepromData load_data; int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData)); @@ -131,12 +175,10 @@ void StateManager::_loadState(AppState& app, byte slot_index) { // 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(load_data.selected_source); app.selected_pulse = static_cast(load_data.selected_pulse); - app.selected_save_slot = slot_index; for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { auto& ch = app.channel[i]; @@ -155,11 +197,25 @@ void StateManager::_loadState(AppState& app, byte slot_index) { interrupts(); } -void StateManager::_saveMetadata() { +void StateManager::_saveMetadata(const AppState& app) { noInterrupts(); Metadata current_meta; strcpy(current_meta.sketch_name, SKETCH_NAME); - current_meta.version = SKETCH_VERSION; - EEPROM.put(0, current_meta); + 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; + + EEPROM.put(METADATA_START_ADDR, current_meta); interrupts(); } + +void StateManager::_loadMetadata(AppState& app) { + noInterrupts(); + Metadata metadata; + EEPROM.get(METADATA_START_ADDR, metadata); + app.selected_save_slot = metadata.selected_save_slot; + app.encoder_reversed = metadata.encoder_reversed; + interrupts(); +} \ No newline at end of file diff --git a/firmware/Gravity/save_state.h b/firmware/Gravity/save_state.h index 354253a..8f25dd1 100644 --- a/firmware/Gravity/save_state.h +++ b/firmware/Gravity/save_state.h @@ -13,21 +13,11 @@ #define SAVE_STATE_H #include -#include +#include // Forward-declare AppState to avoid circular dependencies. struct AppState; -// Define the constants for the current firmware. -const char SKETCH_NAME[] = "Gravity"; -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. -static const unsigned long SAVE_DELAY_MS = 2000; - /** * @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 @@ -38,6 +28,11 @@ static const unsigned long SAVE_DELAY_MS = 2000; */ 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. @@ -52,11 +47,16 @@ class StateManager { 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 { - byte version; char sketch_name[16]; + char version[16]; + // Additional global/hardware settings + byte selected_save_slot; + bool encoder_reversed; }; struct ChannelState { byte base_clock_mod_index; @@ -72,21 +72,24 @@ class StateManager { // This struct holds all the parameters we want to save. struct EepromData { int tempo; - bool encoder_reversed; byte selected_param; byte selected_channel; byte selected_source; byte selected_pulse; - byte selected_save_slot; ChannelState channel_data[Gravity::OUTPUT_COUNT]; }; private: bool _isDataValid(); - void _saveMetadata(); + 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; }; diff --git a/library.properties b/library.properties new file mode 100644 index 0000000..1390c78 --- /dev/null +++ b/library.properties @@ -0,0 +1,10 @@ +name=libGravity +version=2.0.0beta3 +author=Adam Wonak +maintainer=awonak +sentence=Hardware abstraction library for Sitka Instruments Gravity eurorack module +category=Other +license=MIT +url=https://github.com/awonak/libGravity +architectures=avr +depends=uClock,RotaryEncoder,U8g2 \ No newline at end of file diff --git a/analog_input.h b/src/analog_input.h similarity index 100% rename from analog_input.h rename to src/analog_input.h diff --git a/button.h b/src/button.h similarity index 100% rename from button.h rename to src/button.h diff --git a/clock.h b/src/clock.h similarity index 98% rename from clock.h rename to src/clock.h index 9b8ab5e..f7277d7 100644 --- a/clock.h +++ b/src/clock.h @@ -15,7 +15,7 @@ #include #include "peripherials.h" -#include "uClock.h" +#include "uClock/uClock.h" // MIDI clock, start, stop, and continue byte definitions - based on MIDI 1.0 Standards. #define MIDI_CLOCK 0xF8 @@ -56,9 +56,6 @@ class Clock { void Init() { NeoSerial.begin(31250); - // Static pin definition for pulse out. - pinMode(PULSE_OUT_PIN, OUTPUT); - // Initialize the clock library uClock.init(); uClock.setClockMode(uClock.INTERNAL_CLOCK); diff --git a/digital_output.h b/src/digital_output.h similarity index 100% rename from digital_output.h rename to src/digital_output.h diff --git a/encoder.h b/src/encoder.h similarity index 100% rename from encoder.h rename to src/encoder.h diff --git a/gravity.cpp b/src/libGravity.cpp similarity index 97% rename from gravity.cpp rename to src/libGravity.cpp index 23d79f8..c4ae4bc 100644 --- a/gravity.cpp +++ b/src/libGravity.cpp @@ -1,5 +1,5 @@ /** - * @file gravity.cpp + * @file libGravity.cpp * @author Adam Wonak (https://github.com/awonak) * @brief Library for building custom scripts for the Sitka Instruments Gravity module. * @version 0.1 @@ -9,7 +9,7 @@ * */ -#include "gravity.h" +#include "libGravity.h" // Initialize the static pointer for the EncoderDir class to null. We want to // have a static pointer to decouple the ISR from the global gravity object. diff --git a/gravity.h b/src/libGravity.h similarity index 98% rename from gravity.h rename to src/libGravity.h index 00539fb..daf5192 100644 --- a/gravity.h +++ b/src/libGravity.h @@ -1,5 +1,5 @@ /** - * @file gravity.h + * @file libGravity.h * @author Adam Wonak (https://github.com/awonak) * @brief Library for building custom scripts for the Sitka Instruments Gravity module. * @version 0.1 diff --git a/peripherials.h b/src/peripherials.h similarity index 100% rename from peripherials.h rename to src/peripherials.h diff --git a/uClock/platforms/avr.h b/src/uClock/platforms/avr.h similarity index 100% rename from uClock/platforms/avr.h rename to src/uClock/platforms/avr.h diff --git a/uClock.cpp b/src/uClock/uClock.cpp similarity index 99% rename from uClock.cpp rename to src/uClock/uClock.cpp index 1003930..94c432e 100755 --- a/uClock.cpp +++ b/src/uClock/uClock.cpp @@ -32,7 +32,7 @@ * DEALINGS IN THE SOFTWARE. */ #include "uClock.h" -#include "uClock/platforms/avr.h" +#include "platforms/avr.h" // // Platform specific timer setup/control diff --git a/uClock.h b/src/uClock/uClock.h similarity index 100% rename from uClock.h rename to src/uClock/uClock.h diff --git a/uClock/uClock.h b/uClock/uClock.h deleted file mode 100755 index d8670b0..0000000 --- a/uClock/uClock.h +++ /dev/null @@ -1,180 +0,0 @@ -/*! - * @file uClock.h - * Project BPM clock generator for Arduino - * @brief A Library to implement BPM clock tick calls using hardware interruption. Supported and tested on AVR boards(ATmega168/328, ATmega16u4/32u4 and ATmega2560) and ARM boards(RPI2040, Teensy, Seedstudio XIAO M0 and ESP32) - * @version 2.2.1 - * @author Romulo Silva - * @date 10/06/2017 - * @license MIT - (c) 2024 - Romulo Silva - contact@midilab.co - * - * Permission is hereby granted, free of charge, to any person obtaining a - * copy of this software and associated documentation files (the "Software"), - * to deal in the Software without restriction, including without limitation - * the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included - * in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS - * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER - * DEALINGS IN THE SOFTWARE. - */ - -#ifndef __U_CLOCK_H__ -#define __U_CLOCK_H__ - -#include -#include - -namespace umodular { namespace clock { - -#define MIN_BPM 1 -#define MAX_BPM 400 - -#define PHASE_FACTOR 16 -#define PLL_X 220 - -#define SECS_PER_MIN (60UL) -#define SECS_PER_HOUR (3600UL) -#define SECS_PER_DAY (SECS_PER_HOUR * 24L) - -class uClockClass { - - public: - enum ClockMode { - INTERNAL_CLOCK = 0, - EXTERNAL_CLOCK - }; - - enum ClockState { - PAUSED = 0, - STARTING, - STARTED - }; - - enum PPQNResolution { - PPQN_1 = 1, - PPQN_2 = 2, - PPQN_4 = 4, - PPQN_8 = 8, - PPQN_12 = 12, - PPQN_24 = 24, - PPQN_48 = 48, - PPQN_96 = 96, - PPQN_384 = 384, - PPQN_480 = 480, - PPQN_960 = 960 - }; - - ClockState clock_state; - - uClockClass(); - - void setOnOutputPPQN(void (*callback)(uint32_t tick)) { - onOutputPPQNCallback = callback; - } - - void setOnSync24(void (*callback)(uint32_t tick)) { - onSync24Callback = callback; - } - - void setOnClockStart(void (*callback)()) { - onClockStartCallback = callback; - } - - void setOnClockStop(void (*callback)()) { - onClockStopCallback = callback; - } - - void init(); - void setOutputPPQN(PPQNResolution resolution); - void setInputPPQN(PPQNResolution resolution); - - void handleTimerInt(); - void handleExternalClock(); - void resetCounters(); - - // external class control - void start(); - void stop(); - void pause(); - void setTempo(float bpm); - float getTempo(); - - // for software timer implementation(fallback for no board support) - void run(); - - // external timming control - void setClockMode(ClockMode tempo_mode); - ClockMode getClockMode(); - void clockMe(); - // for smooth slave tempo calculate display you should raise the - // buffer_size of ext_interval_buffer in between 64 to 128. 254 max size. - // note: this doesn't impact on sync time, only display time getTempo() - // if you dont want to use it, it is default set it to 1 for memory save - void setExtIntervalBuffer(uint8_t buffer_size); - - // elapsed time support - uint8_t getNumberOfSeconds(uint32_t time); - uint8_t getNumberOfMinutes(uint32_t time); - uint8_t getNumberOfHours(uint32_t time); - uint8_t getNumberOfDays(uint32_t time); - uint32_t getNowTimer(); - uint32_t getPlayTime(); - - uint32_t bpmToMicroSeconds(float bpm); - - private: - float inline freqToBpm(uint32_t freq); - float inline constrainBpm(float bpm); - void calculateReferencedata(); - - void (*onOutputPPQNCallback)(uint32_t tick); - void (*onSync24Callback)(uint32_t tick); - void (*onClockStartCallback)(); - void (*onClockStopCallback)(); - - // clock input/output control - PPQNResolution output_ppqn = PPQN_96; - PPQNResolution input_ppqn = PPQN_24; - // output and internal counters, ticks and references - uint32_t tick; - uint32_t int_clock_tick; - uint8_t mod_clock_counter; - uint16_t mod_clock_ref; - - uint8_t mod_sync24_counter; - uint16_t mod_sync24_ref; - uint32_t sync24_tick; - - // external clock control - volatile uint32_t ext_clock_us; - volatile uint32_t ext_clock_tick; - volatile uint32_t ext_interval; - uint32_t last_interval; - uint32_t sync_interval; - - float tempo; - uint32_t start_timer; - ClockMode clock_mode; - - volatile uint32_t * ext_interval_buffer = nullptr; - uint8_t ext_interval_buffer_size; - uint16_t ext_interval_idx; -}; - -} } // end namespace umodular::clock - -extern umodular::clock::uClockClass uClock; - -extern "C" { - extern volatile uint32_t _millis; -} - -#endif /* __U_CLOCK_H__ */