From 6d38c6b36bcba7728caf605044a597dd8cdef1e3 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 21 Feb 2026 09:39:57 -0800 Subject: [PATCH 1/7] Refactor: remove Euclidean steps into its own firmware --- firmware/Euclidean/Euclidean.ino | 371 ++++++++++ firmware/Euclidean/app_state.h | 41 ++ firmware/Euclidean/channel.h | 247 +++++++ firmware/Euclidean/display.h | 478 +++++++++++++ firmware/{Gravity => Euclidean}/euclidean.h | 0 firmware/Euclidean/save_state.cpp | 222 ++++++ firmware/Euclidean/save_state.h | 93 +++ firmware/Gravity/Gravity.ino | 488 +++++++------ firmware/Gravity/channel.h | 484 +++++++------ firmware/Gravity/display.h | 725 ++++++++++---------- firmware/Gravity/save_state.cpp | 293 ++++---- firmware/Gravity/save_state.h | 129 ++-- 12 files changed, 2510 insertions(+), 1061 deletions(-) create mode 100644 firmware/Euclidean/Euclidean.ino create mode 100644 firmware/Euclidean/app_state.h create mode 100644 firmware/Euclidean/channel.h create mode 100644 firmware/Euclidean/display.h rename firmware/{Gravity => Euclidean}/euclidean.h (100%) create mode 100644 firmware/Euclidean/save_state.cpp create mode 100644 firmware/Euclidean/save_state.h 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 -- 2.39.5 From 763d58f411e189836dce5cde9fe15886554717cf Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 21 Feb 2026 09:44:50 -0800 Subject: [PATCH 2/7] add additional external ppqn --- firmware/Euclidean/display.h | 6 + firmware/Gravity/display.h | 6 + src/clock.h | 287 +++++++++++++++++------------------ 3 files changed, 153 insertions(+), 146 deletions(-) diff --git a/firmware/Euclidean/display.h b/firmware/Euclidean/display.h index 6dd6d59..e679f4a 100644 --- a/firmware/Euclidean/display.h +++ b/firmware/Euclidean/display.h @@ -266,6 +266,12 @@ void DisplayMainPage() { case Clock::SOURCE_EXTERNAL_PPQN_4: subText = F("4 PPQN"); break; + case Clock::SOURCE_EXTERNAL_PPQN_2: + subText = F("2 PPQN"); + break; + case Clock::SOURCE_EXTERNAL_PPQN_1: + subText = F("1 PPQN"); + break; case Clock::SOURCE_EXTERNAL_MIDI: subText = F("MIDI"); break; diff --git a/firmware/Gravity/display.h b/firmware/Gravity/display.h index f265d91..03b8517 100644 --- a/firmware/Gravity/display.h +++ b/firmware/Gravity/display.h @@ -285,6 +285,12 @@ void DisplayMainPage() { case Clock::SOURCE_EXTERNAL_PPQN_4: subText = F("4 PPQN"); break; + case Clock::SOURCE_EXTERNAL_PPQN_2: + subText = F("2 PPQN"); + break; + case Clock::SOURCE_EXTERNAL_PPQN_1: + subText = F("1 PPQN"); + break; case Clock::SOURCE_EXTERNAL_MIDI: subText = F("MIDI"); break; diff --git a/src/clock.h b/src/clock.h index 613667a..ae121cc 100644 --- a/src/clock.h +++ b/src/clock.h @@ -4,7 +4,7 @@ * @brief Wrapper Class for clock timing functions. * @version 0.1 * @date 2025-05-04 - * + * * @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com * */ @@ -17,7 +17,8 @@ #include "peripherials.h" #include "uClock/uClock.h" -// MIDI clock, start, stop, and continue byte definitions - based on MIDI 1.0 Standards. +// MIDI clock, start, stop, and continue byte definitions - based on MIDI 1.0 +// Standards. #define MIDI_CLOCK 0xF8 #define MIDI_START 0xFA #define MIDI_STOP 0xFC @@ -28,164 +29,158 @@ static ExtCallback extUserCallback = nullptr; static void serialEventNoop(uint8_t msg, uint8_t status) {} class Clock { - public: - static constexpr int DEFAULT_TEMPO = 120; +public: + static constexpr int DEFAULT_TEMPO = 120; - enum Source { - SOURCE_INTERNAL, - SOURCE_EXTERNAL_PPQN_24, - SOURCE_EXTERNAL_PPQN_4, - SOURCE_EXTERNAL_MIDI, - SOURCE_LAST, - }; + enum Source { + SOURCE_INTERNAL, + SOURCE_EXTERNAL_PPQN_24, + SOURCE_EXTERNAL_PPQN_4, + SOURCE_EXTERNAL_PPQN_2, + SOURCE_EXTERNAL_PPQN_1, + SOURCE_EXTERNAL_MIDI, + SOURCE_LAST, + }; - enum Pulse { - PULSE_NONE, - PULSE_PPQN_1, - PULSE_PPQN_4, - PULSE_PPQN_24, - PULSE_LAST, - }; + enum Pulse { + PULSE_NONE, + PULSE_PPQN_1, + PULSE_PPQN_4, + PULSE_PPQN_24, + PULSE_LAST, + }; - void Init() { - NeoSerial.begin(31250); + void Init() { + NeoSerial.begin(31250); - // Initialize the clock library - uClock.init(); - uClock.setClockMode(uClock.INTERNAL_CLOCK); - uClock.setOutputPPQN(uClock.PPQN_96); - uClock.setTempo(DEFAULT_TEMPO); + // Initialize the clock library + uClock.init(); + uClock.setClockMode(uClock.INTERNAL_CLOCK); + uClock.setOutputPPQN(uClock.PPQN_96); + uClock.setTempo(DEFAULT_TEMPO); - // MIDI events. - uClock.setOnClockStart(sendMIDIStart); - uClock.setOnClockStop(sendMIDIStop); - uClock.setOnSync24(sendMIDIClock); + // MIDI events. + uClock.setOnClockStart(sendMIDIStart); + uClock.setOnClockStop(sendMIDIStop); + uClock.setOnSync24(sendMIDIClock); - uClock.start(); + uClock.start(); + } + + // Handle external clock tick and call user callback when receiving clock + // trigger (PPQN_4, PPQN_24, or MIDI). + void AttachExtHandler(void (*callback)()) { + extUserCallback = callback; + attachInterrupt(digitalPinToInterrupt(EXT_PIN), callback, RISING); + } + + // Internal PPQN96 callback for all clock timer operations. + void AttachIntHandler(void (*callback)(uint32_t)) { + uClock.setOnOutputPPQN(callback); + } + + // Set the source of the clock mode. + void SetSource(Source source) { + bool was_playing = !IsPaused(); + uClock.stop(); + // If we are changing the source from MIDI, disable the serial interrupt + // handler. + if (source_ == SOURCE_EXTERNAL_MIDI) { + NeoSerial.attachInterrupt(serialEventNoop); } - - // Handle external clock tick and call user callback when receiving clock trigger (PPQN_4, PPQN_24, or MIDI). - void AttachExtHandler(void (*callback)()) { - extUserCallback = callback; - attachInterrupt(digitalPinToInterrupt(EXT_PIN), callback, RISING); + source_ = source; + switch (source) { + case SOURCE_INTERNAL: + uClock.setClockMode(uClock.INTERNAL_CLOCK); + break; + case SOURCE_EXTERNAL_PPQN_24: + uClock.setClockMode(uClock.EXTERNAL_CLOCK); + uClock.setInputPPQN(uClock.PPQN_24); + break; + case SOURCE_EXTERNAL_PPQN_4: + uClock.setClockMode(uClock.EXTERNAL_CLOCK); + uClock.setInputPPQN(uClock.PPQN_4); + break; + case SOURCE_EXTERNAL_PPQN_2: + uClock.setClockMode(uClock.EXTERNAL_CLOCK); + uClock.setInputPPQN(uClock.PPQN_2); + break; + case SOURCE_EXTERNAL_PPQN_1: + uClock.setClockMode(uClock.EXTERNAL_CLOCK); + uClock.setInputPPQN(uClock.PPQN_1); + break; + case SOURCE_EXTERNAL_MIDI: + uClock.setClockMode(uClock.EXTERNAL_CLOCK); + uClock.setInputPPQN(uClock.PPQN_24); + NeoSerial.attachInterrupt(onSerialEvent); + break; } - - // Internal PPQN96 callback for all clock timer operations. - void AttachIntHandler(void (*callback)(uint32_t)) { - uClock.setOnOutputPPQN(callback); + if (was_playing) { + uClock.start(); } + } - // Set the source of the clock mode. - void SetSource(Source source) { - bool was_playing = !IsPaused(); - uClock.stop(); - // If we are changing the source from MIDI, disable the serial interrupt handler. - if (source_ == SOURCE_EXTERNAL_MIDI) { - NeoSerial.attachInterrupt(serialEventNoop); - } - source_ = source; - switch (source) { - case SOURCE_INTERNAL: - uClock.setClockMode(uClock.INTERNAL_CLOCK); - break; - case SOURCE_EXTERNAL_PPQN_24: - uClock.setClockMode(uClock.EXTERNAL_CLOCK); - uClock.setInputPPQN(uClock.PPQN_24); - break; - case SOURCE_EXTERNAL_PPQN_4: - uClock.setClockMode(uClock.EXTERNAL_CLOCK); - uClock.setInputPPQN(uClock.PPQN_4); - break; - case SOURCE_EXTERNAL_MIDI: - uClock.setClockMode(uClock.EXTERNAL_CLOCK); - uClock.setInputPPQN(uClock.PPQN_24); - NeoSerial.attachInterrupt(onSerialEvent); - break; - } - if (was_playing) { - uClock.start(); - } + // Return true if the current selected source is externl (PPQN_4, PPQN_24, or + // MIDI). + bool ExternalSource() { + return uClock.getClockMode() == uClock.EXTERNAL_CLOCK; + } + + // Return true if the current selected source is the internal master clock. + bool InternalSource() { + return uClock.getClockMode() == uClock.INTERNAL_CLOCK; + } + + // Returns the current BPM tempo. + int Tempo() { return uClock.getTempo(); } + + // Set the clock tempo to a int between 1 and 400. + void SetTempo(int tempo) { return uClock.setTempo(tempo); } + + // Record an external clock tick received to process external/internal + // syncronization. + void Tick() { uClock.clockMe(); } + + // Start the internal clock. + void Start() { uClock.start(); } + + // Stop internal clock clock. + void Stop() { uClock.stop(); } + + // Reset all clock counters to 0. + void Reset() { uClock.resetCounters(); } + + // Returns true if the clock is not running. + bool IsPaused() { return uClock.clock_state == uClock.PAUSED; } + +private: + Source source_ = SOURCE_INTERNAL; + + static void onSerialEvent(uint8_t msg, uint8_t status) { + // Note: uClock start and stop will echo to MIDI. + switch (msg) { + case MIDI_CLOCK: + if (extUserCallback) { + extUserCallback(); + } + break; + case MIDI_STOP: + uClock.stop(); + sendMIDIStop(); + break; + case MIDI_START: + case MIDI_CONTINUE: + uClock.start(); + sendMIDIStart(); + break; } + } - // Return true if the current selected source is externl (PPQN_4, PPQN_24, or MIDI). - bool ExternalSource() { - return uClock.getClockMode() == uClock.EXTERNAL_CLOCK; - } + static void sendMIDIStart() { NeoSerial.write(MIDI_START); } - // Return true if the current selected source is the internal master clock. - bool InternalSource() { - return uClock.getClockMode() == uClock.INTERNAL_CLOCK; - } + static void sendMIDIStop() { NeoSerial.write(MIDI_STOP); } - // Returns the current BPM tempo. - int Tempo() { - return uClock.getTempo(); - } - - // Set the clock tempo to a int between 1 and 400. - void SetTempo(int tempo) { - return uClock.setTempo(tempo); - } - - // Record an external clock tick received to process external/internal syncronization. - void Tick() { - uClock.clockMe(); - } - - // Start the internal clock. - void Start() { - uClock.start(); - } - - // Stop internal clock clock. - void Stop() { - uClock.stop(); - } - - // Reset all clock counters to 0. - void Reset() { - uClock.resetCounters(); - } - - // Returns true if the clock is not running. - bool IsPaused() { - return uClock.clock_state == uClock.PAUSED; - } - - private: - Source source_ = SOURCE_INTERNAL; - - static void onSerialEvent(uint8_t msg, uint8_t status) { - // Note: uClock start and stop will echo to MIDI. - switch (msg) { - case MIDI_CLOCK: - if (extUserCallback) { - extUserCallback(); - } - break; - case MIDI_STOP: - uClock.stop(); - sendMIDIStop(); - break; - case MIDI_START: - case MIDI_CONTINUE: - uClock.start(); - sendMIDIStart(); - break; - } - } - - static void sendMIDIStart() { - NeoSerial.write(MIDI_START); - } - - static void sendMIDIStop() { - NeoSerial.write(MIDI_STOP); - } - - static void sendMIDIClock(uint32_t tick) { - NeoSerial.write(MIDI_CLOCK); - } + static void sendMIDIClock(uint32_t tick) { NeoSerial.write(MIDI_CLOCK); } }; #endif \ No newline at end of file -- 2.39.5 From bd08ac435218304b59ca7dd98c0d04b261253f9e Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 21 Feb 2026 10:19:09 -0800 Subject: [PATCH 3/7] Add clock run/reset --- firmware/Euclidean/Euclidean.ino | 31 +++++++ firmware/Euclidean/app_state.h | 32 +++---- firmware/Euclidean/display.h | 35 +++++++- firmware/Euclidean/save_state.cpp | 8 +- firmware/Euclidean/save_state.h | 2 + firmware/Gravity/Gravity.ino | 31 +++++++ firmware/Gravity/app_state.h | 32 +++---- firmware/Gravity/display.h | 35 +++++++- firmware/Gravity/save_state.cpp | 8 +- firmware/Gravity/save_state.h | 2 + src/analog_input.h | 128 +++++++++++++++------------ src/digital_output.h | 142 +++++++++++++++--------------- 12 files changed, 324 insertions(+), 162 deletions(-) diff --git a/firmware/Euclidean/Euclidean.ino b/firmware/Euclidean/Euclidean.ino index c0b221b..02ad5d3 100644 --- a/firmware/Euclidean/Euclidean.ino +++ b/firmware/Euclidean/Euclidean.ino @@ -107,6 +107,28 @@ void loop() { } } + // Clock Run + if (app.cv_run == 1 || app.cv_run == 2) { + auto &cv = app.cv_run == 1 ? gravity.cv1 : gravity.cv2; + int val = cv.Read(); + if (val > AnalogInput::GATE_THRESHOLD && gravity.clock.IsPaused()) { + gravity.clock.Start(); + app.refresh_screen = true; + } else if (val < AnalogInput::GATE_THRESHOLD && !gravity.clock.IsPaused()) { + gravity.clock.Stop(); + ResetOutputs(); + app.refresh_screen = true; + } + } + + // Clock Reset + if ((app.cv_reset == 1 && + gravity.cv1.IsRisingEdge(AnalogInput::GATE_THRESHOLD)) || + (app.cv_reset == 2 && + gravity.cv2.IsRisingEdge(AnalogInput::GATE_THRESHOLD))) { + gravity.clock.Reset(); + } + // Check for dirty state eligible to be saved. stateManager.update(app); @@ -285,6 +307,14 @@ void editMainParameter(int val) { gravity.clock.SetTempo(gravity.clock.Tempo() + val); app.tempo = gravity.clock.Tempo(); break; + case PARAM_MAIN_RUN: + updateSelection(app.selected_sub_param, val, 3); + app.cv_run = app.selected_sub_param; + break; + case PARAM_MAIN_RESET: + updateSelection(app.selected_sub_param, val, 3); + app.cv_reset = app.selected_sub_param; + break; case PARAM_MAIN_SOURCE: { byte source = static_cast(app.selected_source); updateSelection(source, val, Clock::SOURCE_LAST); @@ -301,6 +331,7 @@ void editMainParameter(int val) { } break; } + // These changes are applied upon encoder button press. case PARAM_MAIN_ENCODER_DIR: updateSelection(app.selected_sub_param, val, 2); break; diff --git a/firmware/Euclidean/app_state.h b/firmware/Euclidean/app_state.h index 90712df..7327bfd 100644 --- a/firmware/Euclidean/app_state.h +++ b/firmware/Euclidean/app_state.h @@ -18,24 +18,26 @@ // 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; + int tempo = Clock::DEFAULT_TEMPO; + Channel channel[Gravity::OUTPUT_COUNT]; + byte selected_param = 0; + byte selected_sub_param = 0; // Temporary value for editing params. + byte selected_channel = 0; // 0=tempo, 1-6=output channel + byte selected_swing = 0; + byte selected_save_slot = 0; // The currently active save slot. + Clock::Source selected_source = Clock::SOURCE_INTERNAL; + Clock::Pulse selected_pulse = Clock::PULSE_PPQN_24; + byte cv_run = 0; + byte cv_reset = 0; + bool editing_param = false; + bool encoder_reversed = false; + bool refresh_screen = true; }; extern AppState app; -static Channel& GetSelectedChannel() { - return app.channel[app.selected_channel - 1]; +static Channel &GetSelectedChannel() { + return app.channel[app.selected_channel - 1]; } -#endif // APP_STATE_H \ No newline at end of file +#endif // APP_STATE_H \ No newline at end of file diff --git a/firmware/Euclidean/display.h b/firmware/Euclidean/display.h index e679f4a..9a8666b 100644 --- a/firmware/Euclidean/display.h +++ b/firmware/Euclidean/display.h @@ -127,6 +127,8 @@ constexpr uint8_t CHANNEL_BOX_HEIGHT = 14; // Menu items for editing global parameters. enum ParamsMainPage : uint8_t { PARAM_MAIN_TEMPO, + PARAM_MAIN_RUN, + PARAM_MAIN_RESET, PARAM_MAIN_SOURCE, PARAM_MAIN_PULSE, PARAM_MAIN_ENCODER_DIR, @@ -253,6 +255,34 @@ void DisplayMainPage() { } subText = F("BPM"); break; + case PARAM_MAIN_RUN: + mainText = F("RUN"); + switch (app.cv_run) { + case 0: + subText = F("NONE"); + break; + case 1: + subText = F("CV1 GATE"); + break; + case 2: + subText = F("CV2 GATE"); + break; + } + break; + case PARAM_MAIN_RESET: + mainText = F("RST"); + switch (app.cv_reset) { + case 0: + subText = F("NONE"); + break; + case 1: + subText = F("CV1 TRIG"); + break; + case 2: + subText = F("CV2 TRIG"); + break; + } + break; case PARAM_MAIN_SOURCE: mainText = F("EXT"); switch (app.selected_source) { @@ -339,8 +369,9 @@ void DisplayMainPage() { // 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")}; + F("TEMPO"), F("CLK RUN"), F("CLK RESET"), F("SOURCE"), + F("PULSE OUT"), F("ENCODER DIR"), F("SAVE"), F("LOAD"), + F("RESET"), F("ERASE")}; drawMenuItems(menu_items, PARAM_MAIN_LAST); } diff --git a/firmware/Euclidean/save_state.cpp b/firmware/Euclidean/save_state.cpp index 7aa5c9d..b5bb936 100644 --- a/firmware/Euclidean/save_state.cpp +++ b/firmware/Euclidean/save_state.cpp @@ -18,7 +18,7 @@ // 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 + "V2.0.0BETA3"; // NOTE: This should match the version in the // library.properties file. // Number of available save slots. @@ -94,6 +94,8 @@ void StateManager::reset(AppState &app) { app.selected_channel = default_app.selected_channel; app.selected_source = default_app.selected_source; app.selected_pulse = default_app.selected_pulse; + app.cv_run = default_app.cv_run; + app.cv_reset = default_app.cv_reset; for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { app.channel[i].Init(); @@ -148,6 +150,8 @@ void StateManager::_saveState(const AppState &app, byte slot_index) { 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.cv_run = app.cv_run; + save_data.cv_reset = app.cv_reset; // TODO: break this out into a separate function. Save State should be // broken out into global / per-channel save methods. When saving via @@ -184,6 +188,8 @@ void StateManager::_loadState(AppState &app, byte slot_index) { app.selected_channel = load_data.selected_channel; app.selected_source = static_cast(load_data.selected_source); app.selected_pulse = static_cast(load_data.selected_pulse); + app.cv_run = load_data.cv_run; + app.cv_reset = load_data.cv_reset; for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { auto &ch = app.channel[i]; diff --git a/firmware/Euclidean/save_state.h b/firmware/Euclidean/save_state.h index 0750a26..7a27487 100644 --- a/firmware/Euclidean/save_state.h +++ b/firmware/Euclidean/save_state.h @@ -72,6 +72,8 @@ public: byte selected_channel; byte selected_source; byte selected_pulse; + byte cv_run; + byte cv_reset; ChannelState channel_data[Gravity::OUTPUT_COUNT]; }; diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index 4a08a04..33f7ea6 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -107,6 +107,28 @@ void loop() { } } + // Clock Run + if (app.cv_run == 1 || app.cv_run == 2) { + auto &cv = app.cv_run == 1 ? gravity.cv1 : gravity.cv2; + int val = cv.Read(); + if (val > AnalogInput::GATE_THRESHOLD && gravity.clock.IsPaused()) { + gravity.clock.Start(); + app.refresh_screen = true; + } else if (val < AnalogInput::GATE_THRESHOLD && !gravity.clock.IsPaused()) { + gravity.clock.Stop(); + ResetOutputs(); + app.refresh_screen = true; + } + } + + // Clock Reset + if ((app.cv_reset == 1 && + gravity.cv1.IsRisingEdge(AnalogInput::GATE_THRESHOLD)) || + (app.cv_reset == 2 && + gravity.cv2.IsRisingEdge(AnalogInput::GATE_THRESHOLD))) { + gravity.clock.Reset(); + } + // Check for dirty state eligible to be saved. stateManager.update(app); @@ -285,6 +307,14 @@ void editMainParameter(int val) { gravity.clock.SetTempo(gravity.clock.Tempo() + val); app.tempo = gravity.clock.Tempo(); break; + case PARAM_MAIN_RUN: + updateSelection(app.selected_sub_param, val, 3); + app.cv_run = app.selected_sub_param; + break; + case PARAM_MAIN_RESET: + updateSelection(app.selected_sub_param, val, 3); + app.cv_reset = app.selected_sub_param; + break; case PARAM_MAIN_SOURCE: { byte source = static_cast(app.selected_source); updateSelection(source, val, Clock::SOURCE_LAST); @@ -301,6 +331,7 @@ void editMainParameter(int val) { } break; } + // These changes are applied upon encoder button press. case PARAM_MAIN_ENCODER_DIR: updateSelection(app.selected_sub_param, val, 2); break; diff --git a/firmware/Gravity/app_state.h b/firmware/Gravity/app_state.h index 90712df..f559c0e 100644 --- a/firmware/Gravity/app_state.h +++ b/firmware/Gravity/app_state.h @@ -18,24 +18,26 @@ // 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; + 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. + byte cv_run = 0; + byte cv_reset = 0; + 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]; +static Channel &GetSelectedChannel() { + return app.channel[app.selected_channel - 1]; } -#endif // APP_STATE_H \ No newline at end of file +#endif // APP_STATE_H \ No newline at end of file diff --git a/firmware/Gravity/display.h b/firmware/Gravity/display.h index 03b8517..975478a 100644 --- a/firmware/Gravity/display.h +++ b/firmware/Gravity/display.h @@ -128,6 +128,8 @@ constexpr uint8_t CHANNEL_BOX_HEIGHT = 14; enum ParamsMainPage : uint8_t { PARAM_MAIN_TEMPO, PARAM_MAIN_SOURCE, + PARAM_MAIN_RUN, + PARAM_MAIN_RESET, PARAM_MAIN_PULSE, PARAM_MAIN_ENCODER_DIR, PARAM_MAIN_SAVE_DATA, @@ -296,6 +298,34 @@ void DisplayMainPage() { break; } break; + case PARAM_MAIN_RUN: + mainText = F("RUN"); + switch (app.cv_run) { + case 0: + subText = F("NONE"); + break; + case 1: + subText = F("CV 1"); + break; + case 2: + subText = F("CV 2"); + break; + } + break; + case PARAM_MAIN_RESET: + mainText = F("RST"); + switch (app.cv_reset) { + case 0: + subText = F("NONE"); + break; + case 1: + subText = F("CV 1"); + break; + case 2: + subText = F("CV 2"); + break; + } + break; case PARAM_MAIN_PULSE: mainText = F("OUT"); switch (app.selected_pulse) { @@ -358,8 +388,9 @@ void DisplayMainPage() { // 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")}; + F("TEMPO"), F("SOURCE"), F("CLK RUN"), + F("CLK RESET"), F("PULSE OUT"), F("ENCODER DIR"), + F("SAVE"), F("LOAD"), F("ERASE")}; drawMenuItems(menu_items, PARAM_MAIN_LAST); } diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index 57a4b00..65781c2 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -18,7 +18,7 @@ // 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 + "V2.0.0BETA4"; // NOTE: This should match the version in the // library.properties file. // Number of available save slots. @@ -94,6 +94,8 @@ void StateManager::reset(AppState &app) { app.selected_channel = default_app.selected_channel; app.selected_source = default_app.selected_source; app.selected_pulse = default_app.selected_pulse; + app.cv_run = default_app.cv_run; + app.cv_reset = default_app.cv_reset; for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { app.channel[i].Init(); @@ -148,6 +150,8 @@ void StateManager::_saveState(const AppState &app, byte slot_index) { 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.cv_run = app.cv_run; + save_data.cv_reset = app.cv_reset; // TODO: break this out into a separate function. Save State should be // broken out into global / per-channel save methods. When saving via @@ -185,6 +189,8 @@ void StateManager::_loadState(AppState &app, byte slot_index) { app.selected_channel = load_data.selected_channel; app.selected_source = static_cast(load_data.selected_source); app.selected_pulse = static_cast(load_data.selected_pulse); + app.cv_run = load_data.cv_run; + app.cv_reset = load_data.cv_reset; for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { auto &ch = app.channel[i]; diff --git a/firmware/Gravity/save_state.h b/firmware/Gravity/save_state.h index 5ff1dfe..7bdf85e 100644 --- a/firmware/Gravity/save_state.h +++ b/firmware/Gravity/save_state.h @@ -73,6 +73,8 @@ public: byte selected_channel; byte selected_source; byte selected_pulse; + byte cv_run; + byte cv_reset; ChannelState channel_data[Gravity::OUTPUT_COUNT]; }; diff --git a/src/analog_input.h b/src/analog_input.h index 496899b..bc88039 100644 --- a/src/analog_input.h +++ b/src/analog_input.h @@ -11,78 +11,94 @@ #ifndef ANALOG_INPUT_H #define ANALOG_INPUT_H -const int MAX_INPUT = (1 << 10) - 1; // Max 10 bit analog read resolution. +const int MAX_INPUT = (1 << 10) - 1; // Max 10 bit analog read resolution. // estimated default calibration value const int CALIBRATED_LOW = -566; const int CALIBRATED_HIGH = 512; class AnalogInput { - public: - AnalogInput() {} - ~AnalogInput() {} +public: + static const int GATE_THRESHOLD = 0; - /** - * Initializes a analog input object. - * - * @param pin gpio pin for the analog input. - */ - void Init(uint8_t pin) { - pinMode(pin, INPUT); - pin_ = pin; - } + AnalogInput() {} + ~AnalogInput() {} - /** - * Read the value of the analog input and set instance state. - * - */ - void Process() { - old_read_ = read_; - int raw = analogRead(pin_); - read_ = map(raw, 0, MAX_INPUT, low_, high_); - read_ = constrain(read_ - offset_, -512, 512); - if (inverted_) read_ = -read_; - } + /** + * Initializes a analog input object. + * + * @param pin gpio pin for the analog input. + */ + void Init(uint8_t pin) { + pinMode(pin, INPUT); + pin_ = pin; + } - // Set calibration values. + /** + * Read the value of the analog input and set instance state. + * + */ + void Process() { + old_read_ = read_; + int raw = analogRead(pin_); + read_ = map(raw, 0, MAX_INPUT, low_, high_); + read_ = constrain(read_ - offset_, -512, 512); + if (inverted_) + read_ = -read_; + } - void AdjustCalibrationLow(int amount) { low_ += amount; } + // Set calibration values. - void AdjustCalibrationHigh(int amount) { high_ += amount; } + void AdjustCalibrationLow(int amount) { low_ += amount; } - void SetOffset(float percent) { offset_ = -(percent)*512; } + void AdjustCalibrationHigh(int amount) { high_ += amount; } - void SetAttenuation(float percent) { - low_ = abs(percent) * CALIBRATED_LOW; - high_ = abs(percent) * CALIBRATED_HIGH; - inverted_ = percent < 0; - } + void SetOffset(float percent) { offset_ = -(percent) * 512; } - /** - * Get the current value of the analog input within a range of +/-512. - * - * @return read value within a range of +/-512. - * - */ - inline int16_t Read() { return read_; } + void SetAttenuation(float percent) { + low_ = abs(percent) * CALIBRATED_LOW; + high_ = abs(percent) * CALIBRATED_HIGH; + inverted_ = percent < 0; + } - /** - * Return the analog read value as voltage. - * - * @return A float representing the voltage (-5.0 to +5.0). - * - */ - inline float Voltage() { return ((read_ / 512.0) * 5.0); } + /** + * Get the current value of the analog input within a range of +/-512. + * + * @return read value within a range of +/-512. + * + */ + inline int16_t Read() { return read_; } - private: - uint8_t pin_; - int16_t read_; - uint16_t old_read_; - // calibration values. - int offset_ = 0; - int low_ = CALIBRATED_LOW; - int high_ = CALIBRATED_HIGH; - bool inverted_ = false; + /** + * Return the analog read value as voltage. + * + * @return A float representing the voltage (-5.0 to +5.0). + * + */ + inline float Voltage() { return ((read_ / 512.0) * 5.0); } + + /** + * Checks for a rising edge transition across a threshold. + * + * @param threshold The value that the input must cross. + * @return True if the value just crossed the threshold from below, false + * otherwise. + */ + inline bool IsRisingEdge(int16_t threshold) const { + bool was_high = old_read_ > threshold; + bool is_high = read_ > threshold; + return is_high && !was_high; + } + +private: + uint8_t pin_; + int16_t read_; + uint16_t old_read_; + // calibration values. + int offset_ = 0; + int low_ = CALIBRATED_LOW; + int high_ = CALIBRATED_HIGH; + bool inverted_ = false; }; #endif diff --git a/src/digital_output.h b/src/digital_output.h index 9c4cfc8..5a8d58d 100644 --- a/src/digital_output.h +++ b/src/digital_output.h @@ -4,7 +4,7 @@ * @brief Class for interacting with trigger / gate outputs. * @version 0.1 * @date 2025-04-17 - * + * * @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com * */ @@ -16,79 +16,81 @@ const byte DEFAULT_TRIGGER_DURATION_MS = 5; class DigitalOutput { - public: - /** - * Initializes an CV Output paired object. - * - * @param cv_pin gpio pin for the cv output - */ - void Init(uint8_t cv_pin) { - pinMode(cv_pin, OUTPUT); // Gate/Trigger Output - cv_pin_ = cv_pin; - trigger_duration_ = DEFAULT_TRIGGER_DURATION_MS; +public: + /** + * Initializes an CV Output paired object. + * + * @param cv_pin gpio pin for the cv output + */ + void Init(uint8_t cv_pin) { + pinMode(cv_pin, OUTPUT); // Gate/Trigger Output + cv_pin_ = cv_pin; + trigger_duration_ = DEFAULT_TRIGGER_DURATION_MS; + } + + /** + * Set the trigger duration in miliseconds. + * + * @param duration_ms trigger duration in miliseconds + */ + void SetTriggerDuration(uint8_t duration_ms) { + trigger_duration_ = duration_ms; + } + + /** + * Turn the CV and LED on or off according to the input state. + * + * @param state Arduino digital HIGH or LOW values. + */ + inline void Update(uint8_t state) { + if (state == HIGH) + High(); // Rising + if (state == LOW) + Low(); // Falling + } + + // Sets the cv output HIGH to about 5v. + inline void High() { update(HIGH); } + + // Sets the cv output LOW to 0v. + inline void Low() { update(LOW); } + + /** + * Begin a Trigger period for this output. + */ + inline void Trigger() { + update(HIGH); + last_triggered_ = millis(); + } + + /** + * Return a bool representing the on/off state of the output. + */ + inline void Process() { + // If trigger is HIGH and the trigger duration time has elapsed, set the + // output low. + if (on_ && (millis() - last_triggered_) >= trigger_duration_) { + update(LOW); } + } - /** - * Set the trigger duration in miliseconds. - * - * @param duration_ms trigger duration in miliseconds - */ - void SetTriggerDuration(uint8_t duration_ms) { - trigger_duration_ = duration_ms; - } + /** + * Return a bool representing the on/off state of the output. + * + * @return true if current cv state is high, false if current cv state is low + */ + inline bool On() { return on_; } - /** - * Turn the CV and LED on or off according to the input state. - * - * @param state Arduino digital HIGH or LOW values. - */ - inline void Update(uint8_t state) { - if (state == HIGH) High(); // Rising - if (state == LOW) Low(); // Falling - } +private: + unsigned long last_triggered_; + uint8_t trigger_duration_; + uint8_t cv_pin_; + bool on_; - // Sets the cv output HIGH to about 5v. - inline void High() { update(HIGH); } - - // Sets the cv output LOW to 0v. - inline void Low() { update(LOW); } - - /** - * Begin a Trigger period for this output. - */ - inline void Trigger() { - update(HIGH); - last_triggered_ = millis(); - } - - /** - * Return a bool representing the on/off state of the output. - */ - inline void Process() { - // If trigger is HIGH and the trigger duration time has elapsed, set the output low. - if (on_ && (millis() - last_triggered_) >= trigger_duration_) { - update(LOW); - } - } - - /** - * Return a bool representing the on/off state of the output. - * - * @return true if current cv state is high, false if current cv state is low - */ - inline bool On() { return on_; } - - private: - unsigned long last_triggered_; - uint8_t trigger_duration_; - uint8_t cv_pin_; - uint8_t led_pin_; - bool on_; - - void update(uint8_t state) { - digitalWrite(cv_pin_, state); - on_ = state == HIGH; - } + void update(uint8_t state) { + digitalWrite(cv_pin_, state); + on_ = state == HIGH; + } }; #endif -- 2.39.5 From 624d453b9ddaf75b1c61fa4a9ce494f4055dc303 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 21 Feb 2026 10:33:48 -0800 Subject: [PATCH 4/7] add rotate display --- firmware/Euclidean/Euclidean.ino | 8 ++++++++ firmware/Euclidean/app_state.h | 1 + firmware/Euclidean/display.h | 11 ++++++++--- firmware/Euclidean/save_state.cpp | 4 +++- firmware/Euclidean/save_state.h | 1 + firmware/Gravity/Gravity.ino | 8 ++++++++ firmware/Gravity/app_state.h | 1 + firmware/Gravity/display.h | 11 ++++++++--- firmware/Gravity/save_state.cpp | 4 +++- firmware/Gravity/save_state.h | 1 + 10 files changed, 42 insertions(+), 8 deletions(-) diff --git a/firmware/Euclidean/Euclidean.ino b/firmware/Euclidean/Euclidean.ino index 02ad5d3..6a497e2 100644 --- a/firmware/Euclidean/Euclidean.ino +++ b/firmware/Euclidean/Euclidean.ino @@ -231,6 +231,10 @@ void HandleEncoderPressed() { app.encoder_reversed = app.selected_sub_param == 1; gravity.encoder.SetReverseDirection(app.encoder_reversed); } + if (app.selected_param == PARAM_MAIN_ROTATE_DISP) { + app.rotate_display = app.selected_sub_param == 1; + gravity.display.setFlipMode(app.rotate_display ? 1 : 0); + } if (app.selected_param == PARAM_MAIN_SAVE_DATA) { if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) { app.selected_save_slot = app.selected_sub_param; @@ -335,6 +339,9 @@ void editMainParameter(int val) { case PARAM_MAIN_ENCODER_DIR: updateSelection(app.selected_sub_param, val, 2); break; + case PARAM_MAIN_ROTATE_DISP: + updateSelection(app.selected_sub_param, val, 2); + break; case PARAM_MAIN_SAVE_DATA: case PARAM_MAIN_LOAD_DATA: updateSelection(app.selected_sub_param, val, @@ -393,6 +400,7 @@ void InitGravity(AppState &app) { gravity.clock.SetTempo(app.tempo); gravity.clock.SetSource(app.selected_source); gravity.encoder.SetReverseDirection(app.encoder_reversed); + gravity.display.setFlipMode(app.rotate_display ? 1 : 0); } void ResetOutputs() { diff --git a/firmware/Euclidean/app_state.h b/firmware/Euclidean/app_state.h index 7327bfd..c8b45c0 100644 --- a/firmware/Euclidean/app_state.h +++ b/firmware/Euclidean/app_state.h @@ -31,6 +31,7 @@ struct AppState { byte cv_reset = 0; bool editing_param = false; bool encoder_reversed = false; + bool rotate_display = false; bool refresh_screen = true; }; diff --git a/firmware/Euclidean/display.h b/firmware/Euclidean/display.h index 9a8666b..3752f60 100644 --- a/firmware/Euclidean/display.h +++ b/firmware/Euclidean/display.h @@ -132,6 +132,7 @@ enum ParamsMainPage : uint8_t { PARAM_MAIN_SOURCE, PARAM_MAIN_PULSE, PARAM_MAIN_ENCODER_DIR, + PARAM_MAIN_ROTATE_DISP, PARAM_MAIN_SAVE_DATA, PARAM_MAIN_LOAD_DATA, PARAM_MAIN_RESET_STATE, @@ -328,6 +329,10 @@ void DisplayMainPage() { mainText = F("DIR"); subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("REVERSED"); break; + case PARAM_MAIN_ROTATE_DISP: + mainText = F("DISP"); + subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("ROTATED"); + break; case PARAM_MAIN_SAVE_DATA: case PARAM_MAIN_LOAD_DATA: if (app.selected_sub_param == StateManager::MAX_SAVE_SLOTS) { @@ -369,9 +374,9 @@ void DisplayMainPage() { // Draw Main Page menu items String menu_items[PARAM_MAIN_LAST] = { - F("TEMPO"), F("CLK RUN"), F("CLK RESET"), F("SOURCE"), - F("PULSE OUT"), F("ENCODER DIR"), F("SAVE"), F("LOAD"), - F("RESET"), F("ERASE")}; + F("TEMPO"), F("RUN"), F("RST"), F("SOURCE"), + F("PULSE OUT"), F("ENCODER DIR"), F("ROTATE DISP"), F("SAVE"), + F("LOAD"), F("RESET"), F("ERASE")}; drawMenuItems(menu_items, PARAM_MAIN_LAST); } diff --git a/firmware/Euclidean/save_state.cpp b/firmware/Euclidean/save_state.cpp index b5bb936..8045206 100644 --- a/firmware/Euclidean/save_state.cpp +++ b/firmware/Euclidean/save_state.cpp @@ -18,7 +18,7 @@ // Define the constants for the current firmware. const char StateManager::SKETCH_NAME[] = "ALT EUCLIDEAN"; const char StateManager::SEMANTIC_VERSION[] = - "V2.0.0BETA3"; // NOTE: This should match the version in the + "V2.0.0BETA4"; // NOTE: This should match the version in the // library.properties file. // Number of available save slots. @@ -213,6 +213,7 @@ void StateManager::_saveMetadata(const AppState &app) { // Global user settings current_meta.selected_save_slot = app.selected_save_slot; current_meta.encoder_reversed = app.encoder_reversed; + current_meta.rotate_display = app.rotate_display; EEPROM.put(METADATA_START_ADDR, current_meta); interrupts(); @@ -224,5 +225,6 @@ void StateManager::_loadMetadata(AppState &app) { EEPROM.get(METADATA_START_ADDR, metadata); app.selected_save_slot = metadata.selected_save_slot; app.encoder_reversed = metadata.encoder_reversed; + app.rotate_display = metadata.rotate_display; interrupts(); } \ No newline at end of file diff --git a/firmware/Euclidean/save_state.h b/firmware/Euclidean/save_state.h index 7a27487..d2b9548 100644 --- a/firmware/Euclidean/save_state.h +++ b/firmware/Euclidean/save_state.h @@ -57,6 +57,7 @@ public: // Additional global/hardware settings byte selected_save_slot; bool encoder_reversed; + bool rotate_display; }; struct ChannelState { byte base_clock_mod_index; diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index 33f7ea6..31b5805 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -231,6 +231,10 @@ void HandleEncoderPressed() { app.encoder_reversed = app.selected_sub_param == 1; gravity.encoder.SetReverseDirection(app.encoder_reversed); } + if (app.selected_param == PARAM_MAIN_ROTATE_DISP) { + app.rotate_display = app.selected_sub_param == 1; + gravity.display.setFlipMode(app.rotate_display ? 1 : 0); + } if (app.selected_param == PARAM_MAIN_SAVE_DATA) { if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) { app.selected_save_slot = app.selected_sub_param; @@ -335,6 +339,9 @@ void editMainParameter(int val) { case PARAM_MAIN_ENCODER_DIR: updateSelection(app.selected_sub_param, val, 2); break; + case PARAM_MAIN_ROTATE_DISP: + updateSelection(app.selected_sub_param, val, 2); + break; case PARAM_MAIN_SAVE_DATA: case PARAM_MAIN_LOAD_DATA: updateSelection(app.selected_sub_param, val, @@ -399,6 +406,7 @@ void InitGravity(AppState &app) { gravity.clock.SetTempo(app.tempo); gravity.clock.SetSource(app.selected_source); gravity.encoder.SetReverseDirection(app.encoder_reversed); + gravity.display.setFlipMode(app.rotate_display ? 1 : 0); } void ResetOutputs() { diff --git a/firmware/Gravity/app_state.h b/firmware/Gravity/app_state.h index f559c0e..a62ca00 100644 --- a/firmware/Gravity/app_state.h +++ b/firmware/Gravity/app_state.h @@ -31,6 +31,7 @@ struct AppState { Clock::Pulse selected_pulse = Clock::PULSE_PPQN_24; bool editing_param = false; bool encoder_reversed = false; + bool rotate_display = false; bool refresh_screen = true; }; diff --git a/firmware/Gravity/display.h b/firmware/Gravity/display.h index 975478a..47147ea 100644 --- a/firmware/Gravity/display.h +++ b/firmware/Gravity/display.h @@ -132,6 +132,7 @@ enum ParamsMainPage : uint8_t { PARAM_MAIN_RESET, PARAM_MAIN_PULSE, PARAM_MAIN_ENCODER_DIR, + PARAM_MAIN_ROTATE_DISP, PARAM_MAIN_SAVE_DATA, PARAM_MAIN_LOAD_DATA, PARAM_MAIN_RESET_STATE, @@ -347,6 +348,10 @@ void DisplayMainPage() { mainText = F("DIR"); subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("REVERSED"); break; + case PARAM_MAIN_ROTATE_DISP: + mainText = F("DISP"); + subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("ROTATED"); + break; case PARAM_MAIN_SAVE_DATA: case PARAM_MAIN_LOAD_DATA: if (app.selected_sub_param == StateManager::MAX_SAVE_SLOTS) { @@ -388,9 +393,9 @@ void DisplayMainPage() { // Draw Main Page menu items String menu_items[PARAM_MAIN_LAST] = { - F("TEMPO"), F("SOURCE"), F("CLK RUN"), - F("CLK RESET"), F("PULSE OUT"), F("ENCODER DIR"), - F("SAVE"), F("LOAD"), F("ERASE")}; + F("TEMPO"), F("RUN"), F("RST"), F("SOURCE"), + F("PULSE OUT"), F("ENCODER DIR"), F("ROTATE DISP"), F("SAVE"), + F("LOAD"), F("RESET"), F("ERASE")}; drawMenuItems(menu_items, PARAM_MAIN_LAST); } diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index 65781c2..d51b89a 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -18,7 +18,7 @@ // Define the constants for the current firmware. const char StateManager::SKETCH_NAME[] = "ALT GRAVITY"; const char StateManager::SEMANTIC_VERSION[] = - "V2.0.0BETA4"; // NOTE: This should match the version in the + "V2.0.0BETA5"; // NOTE: This should match the version in the // library.properties file. // Number of available save slots. @@ -215,6 +215,7 @@ void StateManager::_saveMetadata(const AppState &app) { // Global user settings current_meta.selected_save_slot = app.selected_save_slot; current_meta.encoder_reversed = app.encoder_reversed; + current_meta.rotate_display = app.rotate_display; EEPROM.put(METADATA_START_ADDR, current_meta); interrupts(); @@ -226,5 +227,6 @@ void StateManager::_loadMetadata(AppState &app) { EEPROM.get(METADATA_START_ADDR, metadata); app.selected_save_slot = metadata.selected_save_slot; app.encoder_reversed = metadata.encoder_reversed; + app.rotate_display = metadata.rotate_display; interrupts(); } \ No newline at end of file diff --git a/firmware/Gravity/save_state.h b/firmware/Gravity/save_state.h index 7bdf85e..b61d389 100644 --- a/firmware/Gravity/save_state.h +++ b/firmware/Gravity/save_state.h @@ -57,6 +57,7 @@ public: // Additional global/hardware settings byte selected_save_slot; bool encoder_reversed; + bool rotate_display; }; struct ChannelState { byte base_clock_mod_index; -- 2.39.5 From 62a74fe3ee761086db61f95189595f77321cebd0 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 21 Feb 2026 10:45:13 -0800 Subject: [PATCH 5/7] Improve live clock performance when loading pattern data. If the clock is playing, only load pattern and tempo, do not load global settings which impact performance. Refactor the save / load state use of disabling interrupts by wrapping all private methods from inside the public method. This ensures we will not have a race condition if an interrupt is called in between the private method calls. --- firmware/Euclidean/Euclidean.ino | 10 +++++++++- firmware/Euclidean/save_state.cpp | 33 +++++++++++++++++++------------ firmware/Gravity/Gravity.ino | 10 +++++++++- firmware/Gravity/save_state.cpp | 33 +++++++++++++++++++------------ 4 files changed, 58 insertions(+), 28 deletions(-) diff --git a/firmware/Euclidean/Euclidean.ino b/firmware/Euclidean/Euclidean.ino index 6a497e2..f0fdc54 100644 --- a/firmware/Euclidean/Euclidean.ino +++ b/firmware/Euclidean/Euclidean.ino @@ -244,8 +244,16 @@ void HandleEncoderPressed() { if (app.selected_param == PARAM_MAIN_LOAD_DATA) { if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) { app.selected_save_slot = app.selected_sub_param; + // Load pattern data into app state. stateManager.loadData(app, app.selected_save_slot); - InitGravity(app); + // Load global performance settings if they have changed. + if (gravity.clock.Tempo() != app.tempo) { + gravity.clock.SetTempo(app.tempo); + } + // Load global settings only if clock is not active. + if (gravity.clock.IsPaused()) { + InitGravity(app); + } } } if (app.selected_param == PARAM_MAIN_RESET_STATE) { diff --git a/firmware/Euclidean/save_state.cpp b/firmware/Euclidean/save_state.cpp index 8045206..283739c 100644 --- a/firmware/Euclidean/save_state.cpp +++ b/firmware/Euclidean/save_state.cpp @@ -35,20 +35,23 @@ const int StateManager::EEPROM_DATA_START_ADDR = sizeof(StateManager::Metadata); StateManager::StateManager() : _isDirty(false), _lastChangeTime(0) {} bool StateManager::initialize(AppState &app) { + noInterrupts(); + bool success = false; if (_isDataValid()) { // Load global settings. _loadMetadata(app); // Load app data from the transient slot. _loadState(app, TRANSIENT_SLOT); - return true; + success = true; } // EEPROM does not contain save data for this firmware & version. else { // Erase EEPROM and initialize state. Save default pattern to all save // slots. factoryReset(app); - return false; } + interrupts(); + return success; } bool StateManager::loadData(AppState &app, byte slot_index) { @@ -56,38 +59,49 @@ bool StateManager::loadData(AppState &app, byte slot_index) { if (slot_index >= MAX_SAVE_SLOTS + 1) return false; + noInterrupts(); + // Load the state data from the specified EEPROM slot and update the app state // save slot. _loadState(app, slot_index); app.selected_save_slot = slot_index; - // Persist this change in the global metadata. - _saveMetadata(app); + // Persist this change in the global metadata on next update. + _isDirty = true; + interrupts(); return true; } // Save app state to user specified save slot. void StateManager::saveData(const AppState &app) { + noInterrupts(); // Check if slot_index is within max range + 1 for transient. - if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) + if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) { + interrupts(); return; + } _saveState(app, app.selected_save_slot); _saveMetadata(app); _isDirty = false; + interrupts(); } // Save transient state if it has changed and enough time has passed since last // save. void StateManager::update(const AppState &app) { if (_isDirty && (millis() - _lastChangeTime > SAVE_DELAY_MS)) { + noInterrupts(); _saveState(app, TRANSIENT_SLOT); _saveMetadata(app); _isDirty = false; + interrupts(); } } void StateManager::reset(AppState &app) { + noInterrupts(); + AppState default_app; app.tempo = default_app.tempo; app.selected_param = default_app.selected_param; @@ -105,6 +119,7 @@ void StateManager::reset(AppState &app) { _loadMetadata(app); _isDirty = false; + interrupts(); } void StateManager::markDirty() { @@ -142,7 +157,6 @@ void StateManager::_saveState(const AppState &app, byte slot_index) { if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) return; - noInterrupts(); static EepromData save_data; save_data.tempo = app.tempo; @@ -169,7 +183,6 @@ void StateManager::_saveState(const AppState &app, byte slot_index) { int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData)); EEPROM.put(address, save_data); - interrupts(); } void StateManager::_loadState(AppState &app, byte slot_index) { @@ -177,7 +190,6 @@ void StateManager::_loadState(AppState &app, byte slot_index) { 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); @@ -201,11 +213,9 @@ void StateManager::_loadState(AppState &app, byte slot_index) { 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); @@ -216,15 +226,12 @@ void StateManager::_saveMetadata(const AppState &app) { current_meta.rotate_display = app.rotate_display; 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; app.rotate_display = metadata.rotate_display; - interrupts(); } \ No newline at end of file diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index 31b5805..b4f9b84 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -244,8 +244,16 @@ void HandleEncoderPressed() { if (app.selected_param == PARAM_MAIN_LOAD_DATA) { if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) { app.selected_save_slot = app.selected_sub_param; + // Load pattern data into app state. stateManager.loadData(app, app.selected_save_slot); - InitGravity(app); + // Load global performance settings if they have changed. + if (gravity.clock.Tempo() != app.tempo) { + gravity.clock.SetTempo(app.tempo); + } + // Load global settings only if clock is not active. + if (gravity.clock.IsPaused()) { + InitGravity(app); + } } } if (app.selected_param == PARAM_MAIN_RESET_STATE) { diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index d51b89a..6e6c141 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -35,20 +35,23 @@ const int StateManager::EEPROM_DATA_START_ADDR = sizeof(StateManager::Metadata); StateManager::StateManager() : _isDirty(false), _lastChangeTime(0) {} bool StateManager::initialize(AppState &app) { + noInterrupts(); + bool success = false; if (_isDataValid()) { // Load global settings. _loadMetadata(app); // Load app data from the transient slot. _loadState(app, TRANSIENT_SLOT); - return true; + success = true; } // EEPROM does not contain save data for this firmware & version. else { // Erase EEPROM and initialize state. Save default pattern to all save // slots. factoryReset(app); - return false; } + interrupts(); + return success; } bool StateManager::loadData(AppState &app, byte slot_index) { @@ -56,38 +59,49 @@ bool StateManager::loadData(AppState &app, byte slot_index) { if (slot_index >= MAX_SAVE_SLOTS + 1) return false; + noInterrupts(); + // Load the state data from the specified EEPROM slot and update the app state // save slot. _loadState(app, slot_index); app.selected_save_slot = slot_index; - // Persist this change in the global metadata. - _saveMetadata(app); + // Persist this change in the global metadata on next update. + _isDirty = true; + interrupts(); return true; } // Save app state to user specified save slot. void StateManager::saveData(const AppState &app) { + noInterrupts(); // Check if slot_index is within max range + 1 for transient. - if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) + if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) { + interrupts(); return; + } _saveState(app, app.selected_save_slot); _saveMetadata(app); _isDirty = false; + interrupts(); } // Save transient state if it has changed and enough time has passed since last // save. void StateManager::update(const AppState &app) { if (_isDirty && (millis() - _lastChangeTime > SAVE_DELAY_MS)) { + noInterrupts(); _saveState(app, TRANSIENT_SLOT); _saveMetadata(app); _isDirty = false; + interrupts(); } } void StateManager::reset(AppState &app) { + noInterrupts(); + AppState default_app; app.tempo = default_app.tempo; app.selected_param = default_app.selected_param; @@ -105,6 +119,7 @@ void StateManager::reset(AppState &app) { _loadMetadata(app); _isDirty = false; + interrupts(); } void StateManager::markDirty() { @@ -142,7 +157,6 @@ void StateManager::_saveState(const AppState &app, byte slot_index) { if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) return; - noInterrupts(); static EepromData save_data; save_data.tempo = app.tempo; @@ -170,7 +184,6 @@ void StateManager::_saveState(const AppState &app, byte slot_index) { int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData)); EEPROM.put(address, save_data); - interrupts(); } void StateManager::_loadState(AppState &app, byte slot_index) { @@ -178,7 +191,6 @@ void StateManager::_loadState(AppState &app, byte slot_index) { 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); @@ -203,11 +215,9 @@ void StateManager::_loadState(AppState &app, byte slot_index) { 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); @@ -218,15 +228,12 @@ void StateManager::_saveMetadata(const AppState &app) { current_meta.rotate_display = app.rotate_display; 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; app.rotate_display = metadata.rotate_display; - interrupts(); } \ No newline at end of file -- 2.39.5 From ab80642afb8bb37396e21d0ac83f128d9c07f779 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 21 Feb 2026 10:48:45 -0800 Subject: [PATCH 6/7] bump version --- firmware/Euclidean/Euclidean.ino | 7 +++---- firmware/Euclidean/save_state.cpp | 2 +- firmware/Gravity/Gravity.ino | 7 +++---- firmware/Gravity/save_state.cpp | 2 +- library.properties | 3 +-- 5 files changed, 9 insertions(+), 12 deletions(-) diff --git a/firmware/Euclidean/Euclidean.ino b/firmware/Euclidean/Euclidean.ino index f0fdc54..46841f3 100644 --- a/firmware/Euclidean/Euclidean.ino +++ b/firmware/Euclidean/Euclidean.ino @@ -2,11 +2,10 @@ * @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 + * @version v2.0.1beta1 - February 2026 awonak + * @date 2026-02-21 * - * @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com + * @copyright MIT - (c) 2026 - Adam Wonak - adam.wonak@gmail.com * * This version of Gravity firmware is a full rewrite that leverages the * libGravity hardware abstraction library. The goal of this project was to diff --git a/firmware/Euclidean/save_state.cpp b/firmware/Euclidean/save_state.cpp index 283739c..17b6985 100644 --- a/firmware/Euclidean/save_state.cpp +++ b/firmware/Euclidean/save_state.cpp @@ -18,7 +18,7 @@ // Define the constants for the current firmware. const char StateManager::SKETCH_NAME[] = "ALT EUCLIDEAN"; const char StateManager::SEMANTIC_VERSION[] = - "V2.0.0BETA4"; // NOTE: This should match the version in the + "V2.0.1BETA1"; // NOTE: This should match the version in the // library.properties file. // Number of available save slots. diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index b4f9b84..4eb126d 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -2,11 +2,10 @@ * @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 + * @version v2.0.1beta1 - February 2026 awonak + * @date 2026-02-21 * - * @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com + * @copyright MIT - (c) 2026 - Adam Wonak - adam.wonak@gmail.com * * This version of Gravity firmware is a full rewrite that leverages the * libGravity hardware abstraction library. The goal of this project was to diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index 6e6c141..af17a28 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -18,7 +18,7 @@ // Define the constants for the current firmware. const char StateManager::SKETCH_NAME[] = "ALT GRAVITY"; const char StateManager::SEMANTIC_VERSION[] = - "V2.0.0BETA5"; // NOTE: This should match the version in the + "V2.0.1BETA1"; // NOTE: This should match the version in the // library.properties file. // Number of available save slots. diff --git a/library.properties b/library.properties index 77fe46f..5c3a844 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,4 @@ -name=libGravity -version=2.0.0beta2 +version=2.0.1beta1 author=Adam Wonak maintainer=awonak sentence=Hardware abstraction library for Sitka Instruments Gravity eurorack module -- 2.39.5 From de7df4334d2b2dfa3e9180691ea692c1001fb696 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 21 Feb 2026 11:14:46 -0800 Subject: [PATCH 7/7] fix bootsplash --- firmware/Euclidean/display.h | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/firmware/Euclidean/display.h b/firmware/Euclidean/display.h index 3752f60..b8ea4b0 100644 --- a/firmware/Euclidean/display.h +++ b/firmware/Euclidean/display.h @@ -505,8 +505,7 @@ void Bootsplash() { gravity.display.setFont(TEXT_FONT); textWidth = gravity.display.getStrWidth(StateManager::SKETCH_NAME); - gravity.display.drawStr(16 + (textWidth / 2), 20, - StateManager::SKETCH_NAME); + gravity.display.drawStr(4 + (textWidth / 2), 22, StateManager::SKETCH_NAME); textWidth = gravity.display.getStrWidth(StateManager::SEMANTIC_VERSION); gravity.display.drawStr(16 + (textWidth / 2), 32, -- 2.39.5