/** * @file Gravity.ino * @author Adam Wonak (https://github.com/awonak/) * @brief Demo firmware for Sitka Instruments Gravity. * @version 0.1 * @date 2025-05-04 * * @copyright Copyright (c) 2025 * * ENCODER: * Press to change between selecting a parameter and editing the parameter. * Hold & Rotate to change current output channel pattern. * * BTN1: Play/pause the internal clock. * * BTN2: Stop all clocks. * */ #include #include "app_state.h" #include "channel.h" #include "save_state.h" AppState app; StateManager stateManager; enum ParamsMainPage { PARAM_MAIN_TEMPO, PARAM_MAIN_SOURCE, PARAM_MAIN_ENCODER_DIR, PARAM_MAIN_RESET_STATE, PARAM_MAIN_LAST, }; enum ParamsChannelPage { PARAM_CH_MOD, PARAM_CH_PROB, PARAM_CH_DUTY, PARAM_CH_OFFSET, PARAM_CH_CV_SRC, PARAM_CH_CV_DEST, PARAM_CH_LAST, }; const PROGMEM uint8_t TEXT_FONT[437] U8G2_FONT_SECTION("velvetscreen") = "\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"; const PROGMEM uint8_t LARGE_FONT[916] U8G2_FONT_SECTION("stk-l") = "#\0\4\4\4\5\2\1\6\17\30\1\0\27\0\0\0\1\77\0\0\3w%'\17\37\313\330R#&" "\32!F\14\211I\310\24!\65\204(MF\21)Cd\304\10\62b\14\215\60Vb\334\20\0/\14" "\272\336\336d\244\350\263q\343\0\60\37|\377\216!%*\10\35\263\253ChD\30\21bB\14\242S" "\306lv\210\204\22Ef\0\61\24z\337\322\60R\205\314\234\31\61F\310\270\371\177\224\42\3\62\33|" "\377\216)\64*\10\35\63\66r\206\304\314`c\252\34\301\221\263|\360\300\0\63\34|\377\216)\64*" "\10\35\63\66r \71\332YIr\226\306\16\221P\203\312\14\0\64 |\377\226\220AC\306\20\31B" "f\310\240\21\204F\214\32\61j\304(cv\366\200\305\312\371\0\65\32|\377\206\212-F\316\27\204\224" "\254\30\65t\344,\215\35\42\241\6\225\31\0\66\33}\17\317\251\64+\206\235\63:/\314,aA\352" "\234\335\235\42\261&\325\31\0\67\23|\377\302\212\7)\347Crt\70\345\300\221\363\16\0\70 |\377" "\216)\64*\10\35\263\354\20\11\42d\20\235BC\204\4\241cvv\210\204\32Tf\0\71\32|\377" "\216)\64*\10\35\263\263C$\226\250I\71_\14\42\241\6\225\31\0A\26}\17S\271Si(\31" "\65d\324\210q\366\356\301w\366\273\1B$}\17C\42\65KF\221\30\66b\330\210a#\206\215\30" "Eb\311&\243H\14;g\317\36\204`\261\4\0C\27}\17\317\251\64K\10!\63:\377\247\304F" "\20\42\261F\21\22\0D\33}\17C\42\65KF\15\31\66b\330\210q\366\77;\66b\24\211%j" "\22\1E\21|\377\302\7)\347%\42\214F\316/\37<\60F\20|\377\302\7)\347\313\64\331\214\234" "\177\11\0G\31\216\37\17*\65L\206\35\264v>\322\241\15\217\221 \65\204\215\262\63\0H\17|\377" "\302\60\373g\17\36\60\263\177\66\0I\7so\302\37$J\22|\377\346\374\377\322\230\261C\210H\250" "Ae\6\0K\42|\377\302\60S\247F\14\42\61h\310\30\42c&!\63\202\320\251\64JV\14\42" "\61\352\230\375l\0L\15{\357\302\300\371\377\37>x\60\0M$}\17\203\310r\346N\245Q\263\202" "E\12)L\224\60Q\302\310\20#C\214\14\61\23\306L\30s\366\335\0N#}\17\203@s\346\216" "\35C\205*Q\42\23cL\214\61\62\304\310\20\63#\314\214\60\224\25f\327\231\33O\26}\17\317\251" "\64KF\215\30g\377\337\215\30\65dM\252\63\0P\26|\377B\32%+F\35\263W\207H\254H" "\203h\344\374%\0Q\31}\17S\261\64KF\215\30g\377oF\230\31q\246\210\42E%F\0R" "\61\216\37\203\242\65L\206\221\30\67b\334\210q#\306\215\30\67b\30\211QD\230(J\65d\330\230" "Qc\10\315j\314(\42\303H\214\33\61\356\340\0S!\216\37\317\261DKH\221\30\67b\334\210\261" "c)M\246Ji\331\331\32\64\207\212D\223Uh\0T\15}\17\303\7\251\206\316\377\377\12\0U\21" "|\377\302\60\373\377\317F\14\32\242\6\225\31\0X)~\37\303@\203\307H\14\33B\210\14\21RC" "\206\241\63h\222(I\203\346\220\15\31E\204\14!\42\303F\20;h\341\0x\24\312\336\302 CG" "H\240\61E\312\14\222)\6Y\64\0\0\0\0\4\377\377\0"; #define play_icon_width 14 #define play_icon_height 14 static const unsigned char play_icon[] = { 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[] = { 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}; // // Arduino setup and loop. // void setup() { // Start Gravity. gravity.Init(); // Initialize the state manager. This will load settings from EEPROM stateManager.initialize(app); InitAppState(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); gravity.shift_button.AttachPressHandler(HandleShiftPressed); } 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++) { app.channel[i].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; } } if (!app.editing_param) { app.refresh_screen |= refresh; } } void HandleExtClockTick() { // Ignore tick if not using external source. if (!gravity.clock.ExternalSource()) { return; } gravity.clock.Tick(); app.refresh_screen = true; } // // UI handlers for encoder and buttons. // void HandlePlayPressed() { gravity.clock.IsPaused() ? gravity.clock.Start() : gravity.clock.Stop(); ResetOutputs(); app.refresh_screen = true; } void HandleShiftPressed() { 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 if (app.selected_param == PARAM_MAIN_ENCODER_DIR) { bool reversed = app.selected_sub_param == 1; gravity.encoder.SetReverseDirection(reversed); } // Reset state if (app.selected_param == PARAM_MAIN_RESET_STATE) { if (app.selected_sub_param == 0) { // Reset stateManager.reset(app); InitAppState(app); } } } // Only mark dirty when leaving editing mode. stateManager.markDirty(); } app.selected_sub_param = 0; app.editing_param = !app.editing_param; app.refresh_screen = true; } void HandleRotate(Direction dir, int val) { 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(Direction dir, int val) { if (dir == DIRECTION_INCREMENT && app.selected_channel < Gravity::OUTPUT_COUNT) { app.selected_channel++; } else if (dir == DIRECTION_DECREMENT && app.selected_channel > 0) { app.selected_channel--; } 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: { int 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_ENCODER_DIR: updateSelection(app.selected_sub_param, val, 2); break; case PARAM_MAIN_RESET_STATE: 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_CV_SRC: { int source = static_cast(ch.getCvSource()); updateSelection(source, val, CV_LAST); ch.setCvSource(static_cast(source)); break; } case PARAM_CH_CV_DEST: { int dest = static_cast(ch.getCvDestination()); updateSelection(dest, val, CV_DEST_LAST); ch.setCvDestination(static_cast(dest)); break; } } } void updateSelection(int& param, int change, int maxValue) { // This formula correctly handles positive and negative wrapping. param = (param + change % maxValue + maxValue) % maxValue; } // // Helper functions. // void InitAppState(AppState& app) { gravity.clock.SetTempo(app.tempo); gravity.clock.SetSource(app.selected_source); gravity.encoder.SetReverseDirection(app.encoder_reversed); } Channel& GetSelectedChannel() { return app.channel[app.selected_channel - 1]; } void ResetOutputs() { for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { gravity.outputs[i].Low(); } } // // UI Display functions. // // Constants for screen layout and fonts constexpr int SCREEN_CENTER_X = 32; constexpr int MAIN_TEXT_Y = 26; constexpr int SUB_TEXT_Y = 40; constexpr int VISIBLE_MENU_ITEMS = 3; constexpr int MENU_ITEM_HEIGHT = 14; constexpr int MENU_BOX_PADDING = 4; constexpr int MENU_BOX_WIDTH = 64; constexpr int CHANNEL_BOXES_Y = 50; constexpr int CHANNEL_BOX_WIDTH = 18; constexpr int CHANNEL_BOX_HEIGHT = 14; 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 DisplayMainPage() { gravity.display.setFontMode(1); gravity.display.setDrawColor(2); gravity.display.setFont(TEXT_FONT); // Display selected editable value char mainText[8]; const char* 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) { sprintf(mainText, "%s", "EXT"); } else { sprintf(mainText, "%d", gravity.clock.Tempo()); } subText = "BPM"; break; case PARAM_MAIN_SOURCE: switch (app.selected_source) { case Clock::SOURCE_INTERNAL: sprintf(mainText, "%s", "INT"); subText = "CLOCK"; break; case Clock::SOURCE_EXTERNAL_PPQN_24: sprintf(mainText, "%s", "EXT"); subText = "24 PPQN"; break; case Clock::SOURCE_EXTERNAL_PPQN_4: sprintf(mainText, "%s", "EXT"); subText = "4 PPQN"; break; case Clock::SOURCE_EXTERNAL_MIDI: sprintf(mainText, "%s", "EXT"); subText = "MIDI"; break; } break; case PARAM_MAIN_ENCODER_DIR: sprintf(mainText, "%s", "DIR"); subText = app.selected_sub_param == 0 ? "DEFAULT" : "REVERSED"; break; case PARAM_MAIN_RESET_STATE: sprintf(mainText, "%s", "RST"); subText = app.selected_sub_param == 0 ? "RESET ALL" : "BACK"; break; } drawCenteredText(mainText, MAIN_TEXT_Y, LARGE_FONT); drawCenteredText(subText, SUB_TEXT_Y, TEXT_FONT); // Draw Main Page menu items const char* menu_items[PARAM_MAIN_LAST] = {"TEMPO", "SOURCE", "ENCODER DIR", "RESET"}; drawMenuItems(menu_items, PARAM_MAIN_LAST); } void DisplayChannelPage() { auto& ch = GetSelectedChannel(); gravity.display.setFontMode(1); gravity.display.setDrawColor(2); // Display selected editable value char mainText[5]; const char* 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) { sprintf(mainText, "/%d", mod_value); subText = "DIVIDE"; } else { sprintf(mainText, "x%d", abs(mod_value)); subText = "MULTIPLY"; } break; } case PARAM_CH_PROB: sprintf(mainText, "%d%%", ch.getProbability(withCvMod)); subText = "HIT CHANCE"; break; case PARAM_CH_DUTY: sprintf(mainText, "%d%%", ch.getDutyCycle(withCvMod)); subText = "PULSE WIDTH"; break; case PARAM_CH_OFFSET: sprintf(mainText, "%d%%", ch.getOffset(withCvMod)); subText = "SHIFT HIT"; break; case PARAM_CH_CV_SRC: { switch (ch.getCvSource()) { case CV_NONE: sprintf(mainText, "SRC"); subText = "NONE"; break; case CV_1: sprintf(mainText, "SRC"); subText = "CV 1"; break; case CV_2: sprintf(mainText, "SRC"); subText = "CV 2"; break; } break; } case PARAM_CH_CV_DEST: { switch (ch.getCvDestination()) { case CV_DEST_NONE: sprintf(mainText, "DEST"); subText = "NONE"; break; case CV_DEST_MOD: sprintf(mainText, "DEST"); subText = "CLOCK MOD"; break; case CV_DEST_PROB: sprintf(mainText, "DEST"); subText = "PROBABILITY"; break; case CV_DEST_DUTY: sprintf(mainText, "DEST"); subText = "DUTY CYCLE"; break; case CV_DEST_OFFSET: sprintf(mainText, "DEST"); subText = "OFFSET"; break; } break; } } drawCenteredText(mainText, MAIN_TEXT_Y, LARGE_FONT); drawCenteredText(subText, SUB_TEXT_Y, TEXT_FONT); // Draw Channel Page menu items const char* menu_items[PARAM_CH_LAST] = { "MOD", "PROBABILITY", "DUTY", "OFFSET", "CV SOURCE", "CV DEST"}; 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.drawXBM(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 drawMenuItems(const char* 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; app.editing_param ? gravity.display.drawBox(boxX, boxY, boxWidth, boxHeight) : 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], MENU_ITEM_HEIGHT * (i + 1) - 1); } } // 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.getUTF8Width(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.getUTF8Width(text); int drawX = (SCREEN_WIDTH - textWidth) - MENU_BOX_PADDING; gravity.display.drawStr(drawX, y, text); }