diff --git a/firmware/Euclidean/Euclidean.ino b/firmware/Euclidean/Euclidean.ino new file mode 100644 index 0000000..c0b221b --- /dev/null +++ b/firmware/Euclidean/Euclidean.ino @@ -0,0 +1,371 @@ +/** + * @file Gravity.ino + * @author Adam Wonak (https://github.com/awonak/) + * @brief Alt firmware version of Gravity by Sitka Instruments. + * @version v2.0.0 - June 2025 awonak - Full rewrite + * @version v1.0 - August 2023 Oleksiy H - Initial release + * @date 2025-07-04 + * + * @copyright MIT - (c) 2025 - 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 + +#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); + } + } + + // 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_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; + stateManager.loadData(app, app.selected_save_slot); + 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(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_SOURCE: { + byte source = static_cast(app.selected_source); + updateSelection(source, val, Clock::SOURCE_LAST); + app.selected_source = static_cast(source); + gravity.clock.SetSource(app.selected_source); + break; + } + case PARAM_MAIN_PULSE: { + byte pulse = static_cast(app.selected_pulse); + updateSelection(pulse, val, Clock::PULSE_LAST); + app.selected_pulse = static_cast(pulse); + if (app.selected_pulse == Clock::PULSE_NONE) { + gravity.pulse.Low(); + } + break; + } + 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_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(ch.getCv1Dest()); + updateSelection(dest, val, CV_DEST_LAST); + ch.setCv1Dest(static_cast(dest)); + break; + } + case PARAM_CH_CV2_DEST: { + byte dest = static_cast(ch.getCv2Dest()); + updateSelection(dest, val, CV_DEST_LAST); + ch.setCv2Dest(static_cast(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); +} + +void ResetOutputs() { + for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { + gravity.outputs[i].Low(); + } +} diff --git a/firmware/Euclidean/app_state.h b/firmware/Euclidean/app_state.h new file mode 100644 index 0000000..90712df --- /dev/null +++ b/firmware/Euclidean/app_state.h @@ -0,0 +1,41 @@ +/** + * @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 + +#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; + bool editing_param = false; + bool encoder_reversed = false; + bool refresh_screen = true; +}; + +extern AppState app; + +static Channel& GetSelectedChannel() { + return app.channel[app.selected_channel - 1]; +} + +#endif // APP_STATE_H \ No newline at end of file diff --git a/firmware/Euclidean/channel.h b/firmware/Euclidean/channel.h new file mode 100644 index 0000000..85c179a --- /dev/null +++ b/firmware/Euclidean/channel.h @@ -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 +#include + +#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 \ No newline at end of file diff --git a/firmware/Euclidean/display.h b/firmware/Euclidean/display.h new file mode 100644 index 0000000..6dd6d59 --- /dev/null +++ b/firmware/Euclidean/display.h @@ -0,0 +1,478 @@ +/** + * @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 + +#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_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_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_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_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_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("SOURCE"), F("PULSE OUT"), F("ENCODER DIR"), + 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(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()); +} + +#endif // DISPLAY_H diff --git a/firmware/Gravity/euclidean.h b/firmware/Euclidean/euclidean.h similarity index 100% rename from firmware/Gravity/euclidean.h rename to firmware/Euclidean/euclidean.h diff --git a/firmware/Euclidean/save_state.cpp b/firmware/Euclidean/save_state.cpp new file mode 100644 index 0000000..7aa5c9d --- /dev/null +++ b/firmware/Euclidean/save_state.cpp @@ -0,0 +1,222 @@ +/** + * @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 + +#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.0BETA2"; // 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) { + if (_isDataValid()) { + // 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) { + // 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) { + // 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)) { + _saveState(app, TRANSIENT_SLOT); + _saveMetadata(app); + _isDirty = false; + } +} + +void StateManager::reset(AppState &app) { + 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; +} + +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; + + noInterrupts(); + 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(app.selected_source); + save_data.selected_pulse = static_cast(app.selected_pulse); + + // 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(ch.getCv1Dest()); + save_ch.cv2_dest = static_cast(ch.getCv2Dest()); + } + + int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData)); + EEPROM.put(address, save_data); + interrupts(); +} + +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)); + 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(load_data.selected_source); + app.selected_pulse = static_cast(load_data.selected_pulse); + + 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(saved_ch_state.cv1_dest)); + ch.setCv2Dest(static_cast(saved_ch_state.cv2_dest)); + } + interrupts(); +} + +void StateManager::_saveMetadata(const AppState &app) { + noInterrupts(); + 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; + + 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/Euclidean/save_state.h b/firmware/Euclidean/save_state.h new file mode 100644 index 0000000..0750a26 --- /dev/null +++ b/firmware/Euclidean/save_state.h @@ -0,0 +1,93 @@ +/** + * @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 +#include + +// 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; + }; + 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; + 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 \ No newline at end of file diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index ebc0fe3..4a08a04 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -34,17 +34,20 @@ * Play/pause - start or stop the internal clock. * * BTN2: - * Shift - hold and rotate encoder to change current selected output channel. + * 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. - * + * External analog input used to provide modulation to any channel + * parameter. + * * CV2: - * External analog input used to provide modulation to any channel parameter. + * External analog input used to provide modulation to any channel + * parameter. * */ @@ -63,53 +66,53 @@ StateManager stateManager; // void setup() { - // Start Gravity. - gravity.Init(); + // Start Gravity. + gravity.Init(); - // Show bootsplash when initializing firmware. - Bootsplash(); - delay(2000); + // Show bootsplash when initializing firmware. + Bootsplash(); + delay(2000); - // Initialize the state manager. This will load settings from EEPROM - stateManager.initialize(app); - InitGravity(app); + // 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); + // 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); + // 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); + // Button press handlers. + gravity.play_button.AttachPressHandler(HandlePlayPressed); } void loop() { - // Process change in state of inputs and outputs. - gravity.Process(); + // 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(); + // 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); - } + 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); } + } - // Check for dirty state eligible to be saved. - stateManager.update(app); + // Check for dirty state eligible to be saved. + stateManager.update(app); - if (app.refresh_screen) { - UpdateDisplay(); - } + if (app.refresh_screen) { + UpdateDisplay(); + } } // @@ -117,58 +120,59 @@ void loop() { // 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]); + 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; - } + 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; } - // 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); - 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 (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; - } + 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; + 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; } // @@ -176,204 +180,198 @@ 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; + // 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; + 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_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; - stateManager.loadData(app, app.selected_save_slot); - 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); - } - } + // 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_SAVE_DATA) { + if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) { + app.selected_save_slot = app.selected_sub_param; + stateManager.saveData(app); } - // Only mark dirty and reset selected_sub_param when leaving editing mode. - stateManager.markDirty(); - app.selected_sub_param = 0; + } + 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; + stateManager.loadData(app, app.selected_save_slot); + 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; + 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; - } + // 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); + 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 { - // Editing Mode - if (app.selected_channel == 0) { - editMainParameter(val); - } else { - editChannelParameter(val); - } + editChannelParameter(val); } - app.refresh_screen = true; + } + 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; + 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(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_SOURCE: { - byte source = static_cast(app.selected_source); - updateSelection(source, val, Clock::SOURCE_LAST); - app.selected_source = static_cast(source); - gravity.clock.SetSource(app.selected_source); - break; - } - case PARAM_MAIN_PULSE: { - byte pulse = static_cast(app.selected_pulse); - updateSelection(pulse, val, Clock::PULSE_LAST); - app.selected_pulse = static_cast(pulse); - if (app.selected_pulse == Clock::PULSE_NONE) { - gravity.pulse.Low(); - } - break; - } - 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_RESET_STATE: - updateSelection(app.selected_sub_param, val, 2); - break; - case PARAM_MAIN_FACTORY_RESET: - updateSelection(app.selected_sub_param, val, 2); - break; + switch (static_cast(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_SOURCE: { + byte source = static_cast(app.selected_source); + updateSelection(source, val, Clock::SOURCE_LAST); + app.selected_source = static_cast(source); + gravity.clock.SetSource(app.selected_source); + break; + } + case PARAM_MAIN_PULSE: { + byte pulse = static_cast(app.selected_pulse); + updateSelection(pulse, val, Clock::PULSE_LAST); + app.selected_pulse = static_cast(pulse); + if (app.selected_pulse == Clock::PULSE_NONE) { + gravity.pulse.Low(); + } + break; + } + 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_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_PROB: - ch.setProbability(ch.getProbability() + val); - break; - case PARAM_CH_DUTY: - ch.setDutyCycle(ch.getDutyCycle() + val); - break; - case PARAM_CH_OFFSET: - ch.setOffset(ch.getOffset() + val); - break; - case PARAM_CH_SWING: - ch.setSwing(ch.getSwing() + 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(ch.getCv1Dest()); - updateSelection(dest, val, CV_DEST_LAST); - ch.setCv1Dest(static_cast(dest)); - break; - } - case PARAM_CH_CV2_DEST: { - byte dest = static_cast(ch.getCv2Dest()); - updateSelection(dest, val, CV_DEST_LAST); - ch.setCv2Dest(static_cast(dest)); - break; - } - } + auto &ch = GetSelectedChannel(); + switch (app.selected_param) { + case PARAM_CH_MOD: + ch.setClockMod(ch.getClockModIndex() + val); + break; + case PARAM_CH_PROB: + ch.setProbability(ch.getProbability() + val); + break; + case PARAM_CH_DUTY: + ch.setDutyCycle(ch.getDutyCycle() + val); + break; + case PARAM_CH_OFFSET: + ch.setOffset(ch.getOffset() + val); + break; + case PARAM_CH_SWING: + ch.setSwing(ch.getSwing() + val); + break; + case PARAM_CH_CV1_DEST: { + byte dest = static_cast(ch.getCv1Dest()); + updateSelection(dest, val, CV_DEST_LAST); + ch.setCv1Dest(static_cast(dest)); + break; + } + case PARAM_CH_CV2_DEST: { + byte dest = static_cast(ch.getCv2Dest()); + updateSelection(dest, val, CV_DEST_LAST); + ch.setCv2Dest(static_cast(dest)); + break; + } + } } // Changes the param by the value provided. -void updateSelection(byte& param, 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); +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); +void InitGravity(AppState &app) { + gravity.clock.SetTempo(app.tempo); + gravity.clock.SetSource(app.selected_source); + gravity.encoder.SetReverseDirection(app.encoder_reversed); } void ResetOutputs() { - for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { - gravity.outputs[i].Low(); - } + for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { + gravity.outputs[i].Low(); + } } diff --git a/firmware/Gravity/channel.h b/firmware/Gravity/channel.h index df88785..f4635d2 100644 --- a/firmware/Gravity/channel.h +++ b/firmware/Gravity/channel.h @@ -15,19 +15,15 @@ #include #include -#include "euclidean.h" - // Enums for CV Mod destination enum CvDestination : uint8_t { - CV_DEST_NONE, - CV_DEST_MOD, - CV_DEST_PROB, - CV_DEST_DUTY, - CV_DEST_OFFSET, - CV_DEST_SWING, - CV_DEST_EUC_STEPS, - CV_DEST_EUC_HITS, - CV_DEST_LAST, + CV_DEST_NONE, + CV_DEST_MOD, + CV_DEST_PROB, + CV_DEST_DUTY, + CV_DEST_OFFSET, + CV_DEST_SWING, + CV_DEST_LAST, }; static const byte MOD_CHOICE_SIZE = 25; @@ -45,274 +41,256 @@ static const int CLOCK_MOD[MOD_CHOICE_SIZE] PROGMEM = { // 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, + 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 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(); +public: + Channel() { 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() { - // 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; - base_euc_steps = 1; - base_euc_hits = 1; + const uint16_t mod_pulses = + pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]); - 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; - - pattern.Init(DEFAULT_PATTERN); - - // Calcule the clock mod pulses on init. - _recalculatePulses(); + // 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; } - // 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(); + // 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); + if (hit) { + output.High(); } + } } - void setProbability(int prob) { - base_probability = constrain(prob, 0, 100); - if (!isCvModActive()) { - cvmod_probability = base_probability; - _recalculatePulses(); - } + // 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(); + } + } + /** + * @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 setDutyCycle(int duty) { - base_duty_cycle = constrain(duty, 1, 99); - if (!isCvModActive()) { - cvmod_duty_cycle = base_duty_cycle; - _recalculatePulses(); - } + 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 prob_mod = _calculateMod(CV_DEST_PROB, cv1_val, cv2_val, -50, 50); + cvmod_probability = constrain(base_probability + prob_mod, 0, 100); + + int duty_mod = _calculateMod(CV_DEST_DUTY, cv1_val, cv2_val, -50, 50); + cvmod_duty_cycle = constrain(base_duty_cycle + duty_mod, 1, 99); + + 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; } + } - 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(); - } - } + // User-settable base values. + byte base_clock_mod_index; + byte base_probability; + byte base_duty_cycle; + byte base_offset; + byte base_swing; - // 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); - } - } + // Base value with cv mod applied. + byte cvmod_clock_mod_index; + byte cvmod_probability; + byte cvmod_duty_cycle; + byte cvmod_offset; + byte cvmod_swing; - 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; } + // CV mod configuration + CvDestination cv1_dest; + CvDestination cv2_dest; - // Getters (Get the BASE value for editing or cv modded value for display) + // Mute channel flag + bool mute; - 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; } - - 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]); - - // 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(); - } - } - /** - * @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; - } - - 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 prob_mod = _calculateMod(CV_DEST_PROB, cv1_val, cv2_val, -50, 50); - cvmod_probability = constrain(base_probability + prob_mod, 0, 100); - - int duty_mod = _calculateMod(CV_DEST_DUTY, cv1_val, cv2_val, -50, 50); - cvmod_duty_cycle = constrain(base_duty_cycle + duty_mod, 1, 99); - - 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); - - 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 * (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. - byte base_clock_mod_index; - byte base_probability; - byte base_duty_cycle; - byte base_offset; - byte base_swing; - byte base_euc_steps; - byte base_euc_hits; - - // Base value with cv mod applied. - byte cvmod_clock_mod_index; - byte cvmod_probability; - byte cvmod_duty_cycle; - byte cvmod_offset; - byte cvmod_swing; - - // 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; - uint16_t _offset_pulses; - uint16_t _swing_pulse_amount; + // Pre-calculated pulse values for ISR performance + uint16_t _duty_pulses; + uint16_t _offset_pulses; + uint16_t _swing_pulse_amount; }; -#endif // CHANNEL_H \ No newline at end of file +#endif // CHANNEL_H \ No newline at end of file diff --git a/firmware/Gravity/display.h b/firmware/Gravity/display.h index 37b900b..f265d91 100644 --- a/firmware/Gravity/display.h +++ b/firmware/Gravity/display.h @@ -29,17 +29,24 @@ 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;" + "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" + "\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" + "\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" + "\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"; /* @@ -49,40 +56,61 @@ const uint8_t TEXT_FONT[437] U8G2_FONT_SECTION("velvetscreen") 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" - "\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)" + "\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" + "\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"; + "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}; + 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}; + 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; @@ -98,97 +126,98 @@ 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, + 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, + PARAM_CH_MOD, + PARAM_CH_PROB, + PARAM_CH_DUTY, + PARAM_CH_OFFSET, + PARAM_CH_SWING, + 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); +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 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); + 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 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; - } + // 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; + 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); - } + 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; - } + // 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); - } + 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. @@ -197,294 +226,286 @@ inline void hollowTick() { gravity.display.drawBox(56, 4, 4, 4); } // Display an indicator when swing percentage matches a musical note. void swingDivisionMark() { - auto& ch = GetSelectedChannel(); - switch (ch.getSwing()) { - case 58: // 1/32nd - case 66: // 1/16th - case 75: // 1/8th - solidTick(); - break; - case 54: // 1/32nd tripplet - case 62: // 1/16th tripplet - case 71: // 1/8th tripplet - hollowTick(); - break; - } + auto &ch = GetSelectedChannel(); + switch (ch.getSwing()) { + case 58: // 1/32nd + case 66: // 1/16th + case 75: // 1/8th + solidTick(); + break; + case 54: // 1/32nd tripplet + case 62: // 1/16th tripplet + case 71: // 1/8th tripplet + hollowTick(); + break; + } } // 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); - } + 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); + gravity.display.setFontMode(1); + gravity.display.setDrawColor(2); + gravity.display.setFont(TEXT_FONT); - // Display selected editable value - String mainText; - String subText; + // 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_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_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_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; + 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_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_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_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); + 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"), F("ERASE")}; - drawMenuItems(menu_items, PARAM_MAIN_LAST); + // 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"), F("ERASE")}; + drawMenuItems(menu_items, PARAM_MAIN_LAST); } void DisplayChannelPage() { - auto& ch = GetSelectedChannel(); + auto &ch = GetSelectedChannel(); - gravity.display.setFontMode(1); - gravity.display.setDrawColor(2); + gravity.display.setFontMode(1); + gravity.display.setDrawColor(2); - // Display selected editable value - String mainText; - String subText; + // 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; + // 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_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_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_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 = F("EUCLID STEPS"); - break; - case CV_DEST_EUC_HITS: - subText = F("EUCLID HITS"); - break; - } - break; - } + 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_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(subText.c_str(), SUB_TEXT_Y, TEXT_FONT); + 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("PROBABILITY"), F("DUTY"), F("OFFSET"), F("SWING"), F("EUCLID STEPS"), - F("EUCLID HITS"), F("CV1 MOD"), F("CV2 MOD")}; - drawMenuItems(menu_items, PARAM_CH_LAST); + // Draw Channel Page menu items + String menu_items[PARAM_CH_LAST] = { + F("MOD"), F("PROBABILITY"), F("DUTY"), F("OFFSET"), + F("SWING"), 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 + 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); + // 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); + 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); - } + // 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()); + 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); + 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::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(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()); + textWidth = gravity.display.getStrWidth(loadingText.c_str()); + gravity.display.drawStr(26 + (textWidth / 2), 44, loadingText.c_str()); + } while (gravity.display.nextPage()); } -#endif // DISPLAY_H +#endif // DISPLAY_H diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index 96a1f48..57a4b00 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -17,7 +17,9 @@ // Define the constants for the current firmware. const char StateManager::SKETCH_NAME[] = "ALT GRAVITY"; -const char StateManager::SEMANTIC_VERSION[] = "V2.0.0BETA2"; // NOTE: This should match the version in the library.properties file. +const char StateManager::SEMANTIC_VERSION[] = + "V2.0.0BETA2"; // NOTE: This should match the version in the + // library.properties file. // Number of available save slots. const byte StateManager::MAX_SAVE_SLOTS = 10; @@ -32,190 +34,191 @@ const int StateManager::EEPROM_DATA_START_ADDR = sizeof(StateManager::Metadata); StateManager::StateManager() : _isDirty(false), _lastChangeTime(0) {} -bool StateManager::initialize(AppState& app) { - if (_isDataValid()) { - // 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::initialize(AppState &app) { + if (_isDataValid()) { + // 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) { - // Check if slot_index is within max range + 1 for transient. - if (slot_index >= MAX_SAVE_SLOTS + 1) return false; +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; - // 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); + // 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; + return true; } // Save app state to user specified save slot. -void StateManager::saveData(const AppState& app) { - // Check if slot_index is within max range + 1 for transient. - if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) return; +void StateManager::saveData(const AppState &app) { + // 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); + _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)) { + _saveState(app, TRANSIENT_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)) { - _saveState(app, TRANSIENT_SLOT); - _saveMetadata(app); - _isDirty = false; - } -} +void StateManager::reset(AppState &app) { + 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; -void StateManager::reset(AppState& app) { - 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(); + } - for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { - app.channel[i].Init(); - } + // Load global settings from Metadata + _loadMetadata(app); - // Load global settings from Metadata - _loadMetadata(app); - - _isDirty = false; + _isDirty = false; } void StateManager::markDirty() { - _isDirty = true; - _lastChangeTime = millis(); + _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(); +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; + 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; +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; - noInterrupts(); - static EepromData save_data; + noInterrupts(); + 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(app.selected_source); - save_data.selected_pulse = static_cast(app.selected_pulse); + 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(app.selected_source); + save_data.selected_pulse = static_cast(app.selected_pulse); - // 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_probability = ch.getProbability(false); - save_ch.base_duty_cycle = ch.getDutyCycle(false); - save_ch.base_offset = ch.getOffset(false); - save_ch.base_swing = ch.getSwing(false); - save_ch.base_euc_steps = ch.getSteps(false); - save_ch.base_euc_hits = ch.getHits(false); - save_ch.cv1_dest = static_cast(ch.getCv1Dest()); - save_ch.cv2_dest = static_cast(ch.getCv2Dest()); - } + // 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_probability = ch.getProbability(false); + save_ch.base_duty_cycle = ch.getDutyCycle(false); + save_ch.base_offset = ch.getOffset(false); + save_ch.cv1_dest = static_cast(ch.getCv1Dest()); + save_ch.cv2_dest = static_cast(ch.getCv2Dest()); + } - int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData)); - EEPROM.put(address, save_data); - interrupts(); + int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData)); + EEPROM.put(address, save_data); + interrupts(); } -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; +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)); - EEPROM.get(address, load_data); + 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.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); + // 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(load_data.selected_source); + app.selected_pulse = static_cast(load_data.selected_pulse); - for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { - auto& ch = app.channel[i]; - const auto& saved_ch_state = load_data.channel_data[i]; + 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(saved_ch_state.cv1_dest)); - ch.setCv2Dest(static_cast(saved_ch_state.cv2_dest)); - } - interrupts(); + 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.setCv1Dest(static_cast(saved_ch_state.cv1_dest)); + ch.setCv2Dest(static_cast(saved_ch_state.cv2_dest)); + } + interrupts(); } -void StateManager::_saveMetadata(const AppState& app) { - noInterrupts(); - Metadata current_meta; - strcpy(current_meta.sketch_name, SKETCH_NAME); - strcpy(current_meta.version, SEMANTIC_VERSION); +void StateManager::_saveMetadata(const AppState &app) { + noInterrupts(); + 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; + // 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(); + 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(); +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 8f25dd1..5ff1dfe 100644 --- a/firmware/Gravity/save_state.h +++ b/firmware/Gravity/save_state.h @@ -19,79 +19,76 @@ 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. + * @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; +public: + static const char SKETCH_NAME[]; + static const char SEMANTIC_VERSION[]; + static const byte MAX_SAVE_SLOTS; + static const byte TRANSIENT_SLOT; - StateManager(); + 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); + // 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; - }; - struct ChannelState { - byte base_clock_mod_index; - byte base_probability; - byte base_duty_cycle; - byte base_offset; - byte base_swing; - 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; - ChannelState channel_data[Gravity::OUTPUT_COUNT]; - }; + // 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; + }; + struct ChannelState { + byte base_clock_mod_index; + byte base_probability; + byte base_duty_cycle; + byte base_offset; + 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; + 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); +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; + 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; + bool _isDirty; + unsigned long _lastChangeTime; }; -#endif // SAVE_STATE_H \ No newline at end of file +#endif // SAVE_STATE_H \ No newline at end of file