/** * @file Gravity.ino * @author Adam Wonak (https://github.com/awonak/) * @brief Alt firmware version of Gravity by Sitka Instruments. * @version v2.0.0 - August 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(); // 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(); // Check if cv run or reset is active and read cv. CheckRunReset(gravity.cv1, gravity.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; } void CheckRunReset(AnalogInput& cv1, AnalogInput& cv2) { // Clock Run if (app.cv_run == 1 || app.cv_run == 2) { const int val = (app.cv_run == 1) ? cv1.Read() : cv2.Read(); if (val > AnalogInput::GATE_THRESHOLD && gravity.clock.IsPaused()) { gravity.clock.Start(); app.refresh_screen = true; } else if (val < AnalogInput::GATE_THRESHOLD && !gravity.clock.IsPaused()) { gravity.clock.Stop(); ResetOutputs(); app.refresh_screen = true; } } // Clock Reset if ((app.cv_reset == 1 && cv1.IsRisingEdge(AnalogInput::GATE_THRESHOLD)) || (app.cv_reset == 2 && cv2.IsRisingEdge(AnalogInput::GATE_THRESHOLD))) { gravity.clock.Reset(); } } // // 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 switch (app.selected_param) { case PARAM_MAIN_ENCODER_DIR: app.encoder_reversed = app.selected_sub_param == 1; gravity.encoder.SetReverseDirection(app.encoder_reversed); break; case PARAM_MAIN_ROTATE_DISP: app.rotate_display = app.selected_sub_param == 1; gravity.display.setFlipMode(app.rotate_display ? 1 : 0); break; case PARAM_MAIN_SAVE_DATA: if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) { app.selected_save_slot = app.selected_sub_param; stateManager.saveData(app); } break; case PARAM_MAIN_LOAD_DATA: if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) { app.selected_save_slot = app.selected_sub_param; // Load pattern data into app state. stateManager.loadData(app, app.selected_save_slot); // Load global performance settings if they have changed. if (gravity.clock.Tempo() != app.tempo) { gravity.clock.SetTempo(app.tempo); } // Load global settings only clock is not active. if (gravity.clock.IsPaused()) { InitGravity(app); } } break; case PARAM_MAIN_FACTORY_RESET: if (app.selected_sub_param == 0) { // Erase stateManager.factoryReset(app); InitGravity(app); } break; } } // 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_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); 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; } // These changes are applied upon encoder button press. case PARAM_MAIN_ENCODER_DIR: case PARAM_MAIN_ROTATE_DISP: updateSelection(app.selected_sub_param, val, 2); break; case PARAM_MAIN_SAVE_DATA: case PARAM_MAIN_LOAD_DATA: updateSelection(app.selected_sub_param, val, StateManager::MAX_SAVE_SLOTS + 1); break; case PARAM_MAIN_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; } } } // 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); } // // App Helper functions. // void InitGravity(AppState& app) { gravity.clock.SetTempo(app.tempo); gravity.clock.SetSource(app.selected_source); gravity.encoder.SetReverseDirection(app.encoder_reversed); gravity.display.setFlipMode(app.rotate_display ? 1 : 0); } void ResetOutputs() { for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { gravity.outputs[i].Low(); } }