From ed3f8ec31fbbd1d2416fed5da27ffb74d7ca2e0b Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Mon, 26 May 2025 11:16:56 -0700 Subject: [PATCH 01/69] Update uClock to latest version v2.2.1 --- README.md | 12 ++++++++++++ clock.h | 18 +++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index aac097a..41fed09 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,17 @@ Common directory locations: * [Adafruit_GFX](https://github.com/adafruit/Adafruit-GFX-Library) [BSD] - Graphics helper library. * [Adafruit_SSD1306](https://github.com/adafruit/Adafruit_SSD1306) [BSD] - Library for interacting with the SSD1306 OLED display. +> **NOTE:** +> The uClock library needs an additional build parameter passed in order to reduce the amount of dynamic memory it allocates. This can be done by modifying your `arduino.json` file and adding the following: + + ```json + "buildPreferences": [ + ["build.extra_flags" "-DEXT_INTERVAL_BUFFER_SIZE=1"] +] +``` +For additional details, see: https://github.com/midilab/uClock/issues/53 + + ## Example Here's a trivial example showing some of the ways to interact with the library. This script rotates the active clock channel according to the set tempo. The encoder can change the temo or rotation direction. The play/pause button will toggle the clock activity on or off. The shift button will freeze the clock from advancing the channel rotation. @@ -111,3 +122,4 @@ void UpdateDisplay() { gravity.display.display(); } ``` + diff --git a/clock.h b/clock.h index 75f2151..95b7d69 100644 --- a/clock.h +++ b/clock.h @@ -29,8 +29,8 @@ class Clock { void Init() { // Initialize the clock library uClock.init(); - uClock.setMode(uClock.INTERNAL_CLOCK); - uClock.setPPQN(uClock.PPQN_96); + uClock.setClockMode(uClock.INTERNAL_CLOCK); + uClock.setOutputPPQN(uClock.PPQN_96); uClock.setTempo(DEFAULT_TEMPO); uClock.start(); } @@ -42,30 +42,30 @@ class Clock { // Internal PPQN96 callback for all clock timer operations. void AttachIntHandler(void (*callback)(uint32_t)) { - uClock.setOnPPQN(callback); + uClock.setOnOutputPPQN(callback); } // Set the source of the clock mode. void SetSource(Source source) { switch (source) { case SOURCE_INTERNAL: - uClock.setMode(uClock.INTERNAL_CLOCK); + uClock.setClockMode(uClock.INTERNAL_CLOCK); break; case SOURCE_EXTERNAL_PPQN_24: - uClock.setMode(uClock.EXTERNAL_CLOCK); + uClock.setClockMode(uClock.EXTERNAL_CLOCK); case SOURCE_EXTERNAL_PPQN_4: - uClock.setMode(uClock.EXTERNAL_CLOCK); + uClock.setClockMode(uClock.EXTERNAL_CLOCK); default: break; } } bool ExternalSource() { - return uClock.getMode() == uClock.EXTERNAL_CLOCK; + return uClock.getClockMode() == uClock.EXTERNAL_CLOCK; } bool InternalSource() { - return uClock.getMode() == uClock.INTERNAL_CLOCK; + return uClock.getClockMode() == uClock.INTERNAL_CLOCK; } int Tempo() { @@ -85,7 +85,7 @@ class Clock { } bool IsPaused() { - return uClock.state == uClock.PAUSED; + return uClock.clock_state == uClock.PAUSED; } }; -- 2.39.5 From 43e1fe783e64706a77489e7896a0dff357e61df7 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Mon, 26 May 2025 12:29:48 -0700 Subject: [PATCH 02/69] add support for different external clock resolutions --- clock.h | 4 ++++ examples/clock_mod/clock_mod.ino | 28 +++++++++++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/clock.h b/clock.h index 95b7d69..62930cd 100644 --- a/clock.h +++ b/clock.h @@ -22,6 +22,8 @@ enum Source { SOURCE_INTERNAL, SOURCE_EXTERNAL_PPQN_24, SOURCE_EXTERNAL_PPQN_4, + // SOURCE_MIDI, + SOURCE_LAST, }; class Clock { @@ -53,8 +55,10 @@ class Clock { break; case SOURCE_EXTERNAL_PPQN_24: uClock.setClockMode(uClock.EXTERNAL_CLOCK); + uClock.setInputPPQN(uClock.PPQN_24); case SOURCE_EXTERNAL_PPQN_4: uClock.setClockMode(uClock.EXTERNAL_CLOCK); + uClock.setInputPPQN(uClock.PPQN_4); default: break; } diff --git a/examples/clock_mod/clock_mod.ino b/examples/clock_mod/clock_mod.ino index 728f66c..ed495d3 100644 --- a/examples/clock_mod/clock_mod.ino +++ b/examples/clock_mod/clock_mod.ino @@ -33,6 +33,7 @@ struct AppState { bool refresh_screen = true; byte selected_param = 0; byte selected_channel = 0; // 0=tempo, 1-6=output channel + Source selected_source = SOURCE_INTERNAL; Channel channel[OUTPUT_COUNT]; }; AppState app; @@ -155,12 +156,9 @@ void HandleRotate(Direction dir, int val) { app.refresh_screen = true; break; - case 1: - if (gravity.clock.ExternalSource()) { - gravity.clock.SetSource(SOURCE_INTERNAL); - } else { - gravity.clock.SetSource(SOURCE_EXTERNAL_PPQN_24); - } + case 1: + app.selected_source = static_cast((app.selected_source + 1) % SOURCE_LAST); + gravity.clock.SetSource(app.selected_source); app.refresh_screen = true; break; } @@ -261,7 +259,23 @@ void DisplayMainPage() { gravity.display.setCursor(10, 10); gravity.display.print(F("Source: ")); - gravity.display.print((gravity.clock.InternalSource()) ? F("INT") : F("EXT")); + switch (app.selected_source) + { + case SOURCE_INTERNAL: + gravity.display.print(F("INT")); + break; + case SOURCE_EXTERNAL_PPQN_24: + gravity.display.print(F("EXT 24 PPQN")); + break; + case SOURCE_EXTERNAL_PPQN_4: + gravity.display.print(F("EXT 4 PPQN")); + break; + // case SOURCE_EXTERNAL_MIDI: + // gravity.display.print(F("EXT MIDI")); + // break; + default: + break; + } } void DisplayChannelPage() { -- 2.39.5 From 453527c5410d55e54041b965c6f6ccb434fdb9a3 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Tue, 27 May 2025 21:29:31 -0700 Subject: [PATCH 03/69] add select source behavior for new clock resolutions. --- clock.h | 4 +++- examples/clock_mod/clock_mod.ino | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/clock.h b/clock.h index 62930cd..6578b1f 100644 --- a/clock.h +++ b/clock.h @@ -49,6 +49,7 @@ class Clock { // Set the source of the clock mode. void SetSource(Source source) { + uClock.stop(); switch (source) { case SOURCE_INTERNAL: uClock.setClockMode(uClock.INTERNAL_CLOCK); @@ -56,12 +57,13 @@ class Clock { 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); - default: break; } + uClock.start(); } bool ExternalSource() { diff --git a/examples/clock_mod/clock_mod.ino b/examples/clock_mod/clock_mod.ino index 28987c8..fa6979a 100644 --- a/examples/clock_mod/clock_mod.ino +++ b/examples/clock_mod/clock_mod.ino @@ -157,7 +157,12 @@ void HandleRotate(Direction dir, int val) { break; case 1: - app.selected_source = static_cast((app.selected_source + 1) % SOURCE_LAST); + if (static_cast(app.selected_source) == 0 && val < 0) { + app.selected_source = static_cast(SOURCE_LAST - 1); + } else { + app.selected_source = static_cast((app.selected_source + val) % SOURCE_LAST); + } + gravity.clock.SetSource(app.selected_source); app.refresh_screen = true; break; -- 2.39.5 From cbb3d5dbbeeed0a45e90306f3540921691075d61 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Thu, 29 May 2025 20:07:04 -0700 Subject: [PATCH 04/69] remove obsolete flag instruction --- README.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/README.md b/README.md index 41fed09..58d0e58 100644 --- a/README.md +++ b/README.md @@ -19,17 +19,6 @@ Common directory locations: * [Adafruit_GFX](https://github.com/adafruit/Adafruit-GFX-Library) [BSD] - Graphics helper library. * [Adafruit_SSD1306](https://github.com/adafruit/Adafruit_SSD1306) [BSD] - Library for interacting with the SSD1306 OLED display. -> **NOTE:** -> The uClock library needs an additional build parameter passed in order to reduce the amount of dynamic memory it allocates. This can be done by modifying your `arduino.json` file and adding the following: - - ```json - "buildPreferences": [ - ["build.extra_flags" "-DEXT_INTERVAL_BUFFER_SIZE=1"] -] -``` -For additional details, see: https://github.com/midilab/uClock/issues/53 - - ## Example Here's a trivial example showing some of the ways to interact with the library. This script rotates the active clock channel according to the set tempo. The encoder can change the temo or rotation direction. The play/pause button will toggle the clock activity on or off. The shift button will freeze the clock from advancing the channel rotation. -- 2.39.5 From 98c3769008436af4e7ed5e02e1cce689c2ea0290 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Fri, 30 May 2025 03:08:31 +0000 Subject: [PATCH 05/69] Update clock.h to support uClock v2.2.1 and implement external PPQN 4 (#3) Reviewed-on: https://git.pinkduck.xyz/adam/libGravity/pulls/3 Co-authored-by: Adam Wonak Co-committed-by: Adam Wonak --- README.md | 1 + clock.h | 26 ++++++++++++++++---------- examples/clock_mod/clock_mod.ino | 29 ++++++++++++++++++++++++----- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index aac097a..58d0e58 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,4 @@ void UpdateDisplay() { gravity.display.display(); } ``` + diff --git a/clock.h b/clock.h index 75f2151..6578b1f 100644 --- a/clock.h +++ b/clock.h @@ -22,6 +22,8 @@ enum Source { SOURCE_INTERNAL, SOURCE_EXTERNAL_PPQN_24, SOURCE_EXTERNAL_PPQN_4, + // SOURCE_MIDI, + SOURCE_LAST, }; class Clock { @@ -29,8 +31,8 @@ class Clock { void Init() { // Initialize the clock library uClock.init(); - uClock.setMode(uClock.INTERNAL_CLOCK); - uClock.setPPQN(uClock.PPQN_96); + uClock.setClockMode(uClock.INTERNAL_CLOCK); + uClock.setOutputPPQN(uClock.PPQN_96); uClock.setTempo(DEFAULT_TEMPO); uClock.start(); } @@ -42,30 +44,34 @@ class Clock { // Internal PPQN96 callback for all clock timer operations. void AttachIntHandler(void (*callback)(uint32_t)) { - uClock.setOnPPQN(callback); + uClock.setOnOutputPPQN(callback); } // Set the source of the clock mode. void SetSource(Source source) { + uClock.stop(); switch (source) { case SOURCE_INTERNAL: - uClock.setMode(uClock.INTERNAL_CLOCK); + uClock.setClockMode(uClock.INTERNAL_CLOCK); break; case SOURCE_EXTERNAL_PPQN_24: - uClock.setMode(uClock.EXTERNAL_CLOCK); + uClock.setClockMode(uClock.EXTERNAL_CLOCK); + uClock.setInputPPQN(uClock.PPQN_24); + break; case SOURCE_EXTERNAL_PPQN_4: - uClock.setMode(uClock.EXTERNAL_CLOCK); - default: + uClock.setClockMode(uClock.EXTERNAL_CLOCK); + uClock.setInputPPQN(uClock.PPQN_4); break; } + uClock.start(); } bool ExternalSource() { - return uClock.getMode() == uClock.EXTERNAL_CLOCK; + return uClock.getClockMode() == uClock.EXTERNAL_CLOCK; } bool InternalSource() { - return uClock.getMode() == uClock.INTERNAL_CLOCK; + return uClock.getClockMode() == uClock.INTERNAL_CLOCK; } int Tempo() { @@ -85,7 +91,7 @@ class Clock { } bool IsPaused() { - return uClock.state == uClock.PAUSED; + return uClock.clock_state == uClock.PAUSED; } }; diff --git a/examples/clock_mod/clock_mod.ino b/examples/clock_mod/clock_mod.ino index 457253b..fa6979a 100644 --- a/examples/clock_mod/clock_mod.ino +++ b/examples/clock_mod/clock_mod.ino @@ -33,6 +33,7 @@ struct AppState { bool refresh_screen = true; byte selected_param = 0; byte selected_channel = 0; // 0=tempo, 1-6=output channel + Source selected_source = SOURCE_INTERNAL; Channel channel[OUTPUT_COUNT]; }; AppState app; @@ -155,12 +156,14 @@ void HandleRotate(Direction dir, int val) { app.refresh_screen = true; break; - case 1: - if (gravity.clock.ExternalSource()) { - gravity.clock.SetSource(SOURCE_INTERNAL); + case 1: + if (static_cast(app.selected_source) == 0 && val < 0) { + app.selected_source = static_cast(SOURCE_LAST - 1); } else { - gravity.clock.SetSource(SOURCE_EXTERNAL_PPQN_24); + app.selected_source = static_cast((app.selected_source + val) % SOURCE_LAST); } + + gravity.clock.SetSource(app.selected_source); app.refresh_screen = true; break; } @@ -261,7 +264,23 @@ void DisplayMainPage() { gravity.display.setCursor(10, 10); gravity.display.print(F("Source: ")); - gravity.display.print((gravity.clock.InternalSource()) ? F("INT") : F("EXT")); + switch (app.selected_source) + { + case SOURCE_INTERNAL: + gravity.display.print(F("INT")); + break; + case SOURCE_EXTERNAL_PPQN_24: + gravity.display.print(F("EXT 24 PPQN")); + break; + case SOURCE_EXTERNAL_PPQN_4: + gravity.display.print(F("EXT 4 PPQN")); + break; + // case SOURCE_EXTERNAL_MIDI: + // gravity.display.print(F("EXT MIDI")); + // break; + default: + break; + } } void DisplayChannelPage() { -- 2.39.5 From 18e53e90c12f897c0e8a65f56a2a5d89d181473d Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 31 May 2025 17:35:04 -0700 Subject: [PATCH 06/69] Fuck it, we're doing pointers for everything! --- README.md | 1 - encoder_dir.h | 27 ++++--- examples/clock_mod/clock_mod.ino | 127 +++++++++++++++---------------- gravity.cpp | 43 ++++++----- gravity.h | 17 ++--- peripherials.h | 1 + 6 files changed, 113 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 58d0e58..aac097a 100644 --- a/README.md +++ b/README.md @@ -111,4 +111,3 @@ void UpdateDisplay() { gravity.display.display(); } ``` - diff --git a/encoder_dir.h b/encoder_dir.h index dbc3c56..2a55bf8 100644 --- a/encoder_dir.h +++ b/encoder_dir.h @@ -33,8 +33,11 @@ class EncoderDir { Direction dir; public: - EncoderDir() : encoder_(ENCODER_PIN1, ENCODER_PIN2, RotaryEncoder::LatchMode::FOUR3), - button_(ENCODER_SW_PIN) {} + EncoderDir() { + encoder_ = new RotaryEncoder(ENCODER_PIN1, ENCODER_PIN2, RotaryEncoder::LatchMode::FOUR3); + button_ = new Button(ENCODER_SW_PIN); + } + ~EncoderDir() {} // Set to true if the encoder read direction should be reversed. @@ -55,15 +58,15 @@ class EncoderDir { // Parse EncoderButton increment direction. Direction RotateDirection() { - int dir = (int)(encoder_.getDirection()); + int dir = (int)(encoder_->getDirection()); return rotate_(dir, reversed_); } void Process() { // Get encoder position change amount. int encoder_rotated = _rotate_change() != 0; - bool button_pressed = button_.On(); - button_.Process(); + bool button_pressed = button_->On(); + button_->Process(); // Handle encoder position change and button press. if (button_pressed && encoder_rotated) { @@ -71,32 +74,32 @@ class EncoderDir { if (on_press_rotate != NULL) on_press_rotate(dir, change); } else if (!button_pressed && encoder_rotated) { if (on_rotate != NULL) on_rotate(dir, change); - } else if (button_.Change() == Button::CHANGE_RELEASED && !rotated_while_held_) { + } else if (button_->Change() == Button::CHANGE_RELEASED && !rotated_while_held_) { if (on_press != NULL) on_press(); } // Reset rotate while held state. - if (button_.Change() == Button::CHANGE_RELEASED && rotated_while_held_) { + if (button_->Change() == Button::CHANGE_RELEASED && rotated_while_held_) { rotated_while_held_ = false; } } // Read the encoder state and update the read position. void UpdateEncoder() { - encoder_.tick(); + encoder_->tick(); } private: int previous_pos_; bool rotated_while_held_; bool reversed_ = true; - RotaryEncoder encoder_; - Button button_; + RotaryEncoder * encoder_ = nullptr; + Button * button_ = nullptr; // Return the number of ticks change since last polled. int _rotate_change() { - int position = encoder_.getPosition(); - unsigned long ms = encoder_.getMillisBetweenRotations(); + int position = encoder_->getPosition(); + unsigned long ms = encoder_->getMillisBetweenRotations(); // Validation (TODO: add debounce check). if (previous_pos_ == position) { diff --git a/examples/clock_mod/clock_mod.ino b/examples/clock_mod/clock_mod.ino index fa6979a..3a45299 100644 --- a/examples/clock_mod/clock_mod.ino +++ b/examples/clock_mod/clock_mod.ino @@ -61,16 +61,16 @@ void setup() { gravity.Init(); // Clock handlers. - gravity.clock.AttachExtHandler(ExtClock); - gravity.clock.AttachIntHandler(IntClock); + gravity.clock->AttachExtHandler(ExtClock); + gravity.clock->AttachIntHandler(IntClock); // Encoder rotate and press handlers. - gravity.encoder.AttachPressHandler(HandleEncoderPressed); - gravity.encoder.AttachRotateHandler(HandleRotate); - gravity.encoder.AttachPressRotateHandler(HandlePressedRotate); + gravity.encoder->AttachPressHandler(HandleEncoderPressed); + gravity.encoder->AttachRotateHandler(HandleRotate); + gravity.encoder->AttachPressRotateHandler(HandlePressedRotate); // Button press handlers. - gravity.play_button.AttachPressHandler(HandlePlayPressed); + gravity.play_button->AttachPressHandler(HandlePlayPressed); } void loop() { @@ -88,8 +88,8 @@ void loop() { // void ExtClock() { - if (gravity.clock.ExternalSource()) { - gravity.clock.Tick(); + if (gravity.clock->ExternalSource()) { + gravity.clock->Tick(); app.refresh_screen = true; } } @@ -98,7 +98,7 @@ void IntClock(uint32_t tick) { for (int i = 0; i < OUTPUT_COUNT; i++) { const auto& channel = app.channel[i]; auto& output = gravity.outputs[i]; - + const uint32_t mod_pulses = clock_mod_pulses[channel.clock_mod_index]; const uint32_t current_tick_offset = tick + channel.offset_pulses; @@ -119,8 +119,8 @@ void IntClock(uint32_t tick) { } void HandlePlayPressed() { - gravity.clock.Pause(); - if (gravity.clock.IsPaused()) { + gravity.clock->Pause(); + if (gravity.clock->IsPaused()) { for (int i = 0; i < OUTPUT_COUNT; i++) { gravity.outputs[i].Low(); } @@ -149,21 +149,21 @@ void HandleRotate(Direction dir, int val) { if (app.selected_channel == 0) { switch (app.selected_param) { case 0: - if (gravity.clock.ExternalSource()) { + if (gravity.clock->ExternalSource()) { break; } - gravity.clock.SetTempo(gravity.clock.Tempo() + val); + gravity.clock->SetTempo(gravity.clock->Tempo() + val); app.refresh_screen = true; break; - case 1: + case 1: if (static_cast(app.selected_source) == 0 && val < 0) { app.selected_source = static_cast(SOURCE_LAST - 1); } else { app.selected_source = static_cast((app.selected_source + val) % SOURCE_LAST); } - gravity.clock.SetSource(app.selected_source); + gravity.clock->SetSource(app.selected_source); app.refresh_screen = true; break; } @@ -222,7 +222,7 @@ Channel& GetSelectedChannel() { void UpdateDisplay() { app.refresh_screen = false; - gravity.display.clearDisplay(); + gravity.display->clearDisplay(); if (app.selected_channel == 0) { DisplayMainPage(); @@ -231,83 +231,82 @@ void UpdateDisplay() { } // Show selected param indicator - gravity.display.drawChar(0, app.selected_param * 10, 0x10, 1, 0, 1); + gravity.display->drawChar(0, app.selected_param * 10, 0x10, 1, 0, 1); // Global channel select UI. DisplaySelectedChannel(); - gravity.display.display(); + gravity.display->display(); } void DisplaySelectedChannel() { - gravity.display.drawLine(1, 52, 126, 52, 1); + gravity.display->drawLine(1, 52, 126, 52, 1); for (int i = 0; i < 7; i++) { (app.selected_channel == i) - ? gravity.display.fillRect(i * 18, 52, 18, 12, 1) - : gravity.display.drawLine(i * 18, 52, i * 18, 64, 1); + ? gravity.display->fillRect(i * 18, 52, 18, 12, 1) + : gravity.display->drawLine(i * 18, 52, i * 18, 64, 1); int selected = app.selected_channel == i; if (i == 0) { - char icon = gravity.clock.IsPaused() ? CHAR_PAUSE : CHAR_PLAY; - gravity.display.drawChar((i * 18) + 7, 55, icon, !selected, selected, 1); + char icon = gravity.clock->IsPaused() ? CHAR_PAUSE : CHAR_PLAY; + gravity.display->drawChar((i * 18) + 7, 55, icon, !selected, selected, 1); } else { - gravity.display.drawChar((i * 18) + 7, 55, i + 48, !selected, selected, 1); + gravity.display->drawChar((i * 18) + 7, 55, i + 48, !selected, selected, 1); } } - gravity.display.drawLine(126, 52, 126, 64, 1); + gravity.display->drawLine(126, 52, 126, 64, 1); } void DisplayMainPage() { - gravity.display.setCursor(10, 0); - gravity.display.print(F("Tempo: ")); - gravity.display.print(gravity.clock.Tempo()); + gravity.display->setCursor(10, 0); + gravity.display->print(F("Tempo: ")); + gravity.display->print(gravity.clock->Tempo()); - gravity.display.setCursor(10, 10); - gravity.display.print(F("Source: ")); - switch (app.selected_source) - { - case SOURCE_INTERNAL: - gravity.display.print(F("INT")); - break; - case SOURCE_EXTERNAL_PPQN_24: - gravity.display.print(F("EXT 24 PPQN")); - break; - case SOURCE_EXTERNAL_PPQN_4: - gravity.display.print(F("EXT 4 PPQN")); - break; - // case SOURCE_EXTERNAL_MIDI: - // gravity.display.print(F("EXT MIDI")); - // break; - default: - break; + gravity.display->setCursor(10, 10); + gravity.display->print(F("Source: ")); + switch (app.selected_source) { + case SOURCE_INTERNAL: + gravity.display->print(F("INT")); + break; + case SOURCE_EXTERNAL_PPQN_24: + gravity.display->print(F("EXT 24 PPQN")); + break; + case SOURCE_EXTERNAL_PPQN_4: + gravity.display->print(F("EXT 4 PPQN")); + break; + // case SOURCE_EXTERNAL_MIDI: + // gravity.display->print(F("EXT MIDI")); + // break; + default: + break; } } void DisplayChannelPage() { auto& ch = GetSelectedChannel(); - gravity.display.setCursor(10, 0); - gravity.display.print(F("Mod: ")); + gravity.display->setCursor(10, 0); + gravity.display->print(F("Mod: ")); if (clock_mod[ch.clock_mod_index] > 1) { - gravity.display.print(F("/ ")); - gravity.display.print(clock_mod[ch.clock_mod_index]); + gravity.display->print(F("/ ")); + gravity.display->print(clock_mod[ch.clock_mod_index]); } else { - gravity.display.print(F("X ")); - gravity.display.print(abs(clock_mod[ch.clock_mod_index])); + gravity.display->print(F("X ")); + gravity.display->print(abs(clock_mod[ch.clock_mod_index])); } - gravity.display.setCursor(10, 10); - gravity.display.print(F("Probability: ")); - gravity.display.print(ch.probability); - gravity.display.print(F("%")); + gravity.display->setCursor(10, 10); + gravity.display->print(F("Probability: ")); + gravity.display->print(ch.probability); + gravity.display->print(F("%")); - gravity.display.setCursor(10, 20); - gravity.display.print(F("Duty Cycle: ")); - gravity.display.print(ch.duty_cycle); - gravity.display.print(F("%")); + gravity.display->setCursor(10, 20); + gravity.display->print(F("Duty Cycle: ")); + gravity.display->print(ch.duty_cycle); + gravity.display->print(F("%")); - gravity.display.setCursor(10, 30); - gravity.display.print(F("Offset: ")); - gravity.display.print(ch.offset); - gravity.display.print(F("%")); + gravity.display->setCursor(10, 30); + gravity.display->print(F("Offset: ")); + gravity.display->print(ch.offset); + gravity.display->print(F("%")); } \ No newline at end of file diff --git a/gravity.cpp b/gravity.cpp index 2cfbb8b..6ba4a36 100644 --- a/gravity.cpp +++ b/gravity.cpp @@ -19,15 +19,22 @@ void Gravity::Init() { } void Gravity::InitClock() { - clock.Init(); + clock = new Clock(); + clock->Init(); } void Gravity::InitInputs() { - shift_button.Init(SHIFT_BTN_PIN); - play_button.Init(PLAY_BTN_PIN); + shift_button = new Button(); + play_button = new Button(); + shift_button->Init(SHIFT_BTN_PIN); + play_button->Init(PLAY_BTN_PIN); - cv1.Init(CV1_PIN); - cv2.Init(CV2_PIN); + cv1 = new AnalogInput(); + cv2 = new AnalogInput(); + cv1->Init(CV1_PIN); + cv2->Init(CV2_PIN); + + encoder = new EncoderDir(); // Pin Change Interrupts for Encoder. // Thanks to https://dronebotworkshop.com/interrupts/ @@ -50,23 +57,25 @@ void Gravity::InitOutputs() { outputs[5].Init(OUT_CH6); } void Gravity::InitDisplay() { + display = new Adafruit_SSD1306(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); + // OLED Display configuration. - display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS); + display->begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS); delay(1000); - display.setRotation(2); // rotates text on OLED 1=90 degrees, 2=180 degrees - display.clearDisplay(); - display.setTextSize(1); - display.setTextColor(WHITE); - display.display(); + display->setRotation(2); // rotates text on OLED 1=90 degrees, 2=180 degrees + display->clearDisplay(); + display->setTextSize(1); + display->setTextColor(WHITE); + display->display(); } void Gravity::Process() { // Read peripherials for changes. - shift_button.Process(); - play_button.Process(); - encoder.Process(); - cv1.Process(); - cv2.Process(); + shift_button->Process(); + play_button->Process(); + encoder->Process(); + cv1->Process(); + cv2->Process(); // Update Output states. for (int i; i < OUTPUT_COUNT; i++) { @@ -75,7 +84,7 @@ void Gravity::Process() { } void ReadEncoder() { - gravity.encoder.UpdateEncoder(); + gravity.encoder->UpdateEncoder(); } // Define Encoder pin ISR. diff --git a/gravity.h b/gravity.h index 698ebcb..2d8909d 100644 --- a/gravity.h +++ b/gravity.h @@ -16,8 +16,7 @@ class Gravity { public: // Constructor - Gravity() - : display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1) {} + Gravity() {} // Deconstructor ~Gravity() {} @@ -28,14 +27,14 @@ class Gravity { // Polling check for state change of inputs and outputs. void Process(); - Adafruit_SSD1306 display; // OLED display object. - Clock clock; // Clock source wrapper. + Adafruit_SSD1306 * display = nullptr; // OLED display object. + Clock * clock = nullptr; // Clock source wrapper. DigitalOutput outputs[OUTPUT_COUNT]; // An array containing each Output object. - EncoderDir encoder; // Rotary encoder with button instance - Button shift_button; - Button play_button; - AnalogInput cv1; - AnalogInput cv2; + EncoderDir * encoder = nullptr; // Rotary encoder with button instance + Button * shift_button = nullptr; + Button * play_button = nullptr; + AnalogInput * cv1 = nullptr; + AnalogInput * cv2 = nullptr; private: void InitClock(); diff --git a/peripherials.h b/peripherials.h index 5ec0539..1b2abbb 100644 --- a/peripherials.h +++ b/peripherials.h @@ -15,6 +15,7 @@ #define OLED_ADDRESS 0x3C #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 +#define OLED_RESET -1 // Peripheral input pins #define ENCODER_PIN1 17 // A3 -- 2.39.5 From edd7df1cd6828c79d8b7697d2a6f565e0932864d Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 31 May 2025 20:28:17 -0700 Subject: [PATCH 07/69] Revert pointer change. not worth the few bytes it saved. --- README.md | 1 + encoder_dir.h | 27 +++---- examples/clock_mod/clock_mod.ino | 127 ++++++++++++++++--------------- gravity.cpp | 43 +++++------ gravity.h | 17 +++-- peripherials.h | 1 - 6 files changed, 103 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index aac097a..58d0e58 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,4 @@ void UpdateDisplay() { gravity.display.display(); } ``` + diff --git a/encoder_dir.h b/encoder_dir.h index 2a55bf8..dbc3c56 100644 --- a/encoder_dir.h +++ b/encoder_dir.h @@ -33,11 +33,8 @@ class EncoderDir { Direction dir; public: - EncoderDir() { - encoder_ = new RotaryEncoder(ENCODER_PIN1, ENCODER_PIN2, RotaryEncoder::LatchMode::FOUR3); - button_ = new Button(ENCODER_SW_PIN); - } - + EncoderDir() : encoder_(ENCODER_PIN1, ENCODER_PIN2, RotaryEncoder::LatchMode::FOUR3), + button_(ENCODER_SW_PIN) {} ~EncoderDir() {} // Set to true if the encoder read direction should be reversed. @@ -58,15 +55,15 @@ class EncoderDir { // Parse EncoderButton increment direction. Direction RotateDirection() { - int dir = (int)(encoder_->getDirection()); + int dir = (int)(encoder_.getDirection()); return rotate_(dir, reversed_); } void Process() { // Get encoder position change amount. int encoder_rotated = _rotate_change() != 0; - bool button_pressed = button_->On(); - button_->Process(); + bool button_pressed = button_.On(); + button_.Process(); // Handle encoder position change and button press. if (button_pressed && encoder_rotated) { @@ -74,32 +71,32 @@ class EncoderDir { if (on_press_rotate != NULL) on_press_rotate(dir, change); } else if (!button_pressed && encoder_rotated) { if (on_rotate != NULL) on_rotate(dir, change); - } else if (button_->Change() == Button::CHANGE_RELEASED && !rotated_while_held_) { + } else if (button_.Change() == Button::CHANGE_RELEASED && !rotated_while_held_) { if (on_press != NULL) on_press(); } // Reset rotate while held state. - if (button_->Change() == Button::CHANGE_RELEASED && rotated_while_held_) { + if (button_.Change() == Button::CHANGE_RELEASED && rotated_while_held_) { rotated_while_held_ = false; } } // Read the encoder state and update the read position. void UpdateEncoder() { - encoder_->tick(); + encoder_.tick(); } private: int previous_pos_; bool rotated_while_held_; bool reversed_ = true; - RotaryEncoder * encoder_ = nullptr; - Button * button_ = nullptr; + RotaryEncoder encoder_; + Button button_; // Return the number of ticks change since last polled. int _rotate_change() { - int position = encoder_->getPosition(); - unsigned long ms = encoder_->getMillisBetweenRotations(); + int position = encoder_.getPosition(); + unsigned long ms = encoder_.getMillisBetweenRotations(); // Validation (TODO: add debounce check). if (previous_pos_ == position) { diff --git a/examples/clock_mod/clock_mod.ino b/examples/clock_mod/clock_mod.ino index 3a45299..fa6979a 100644 --- a/examples/clock_mod/clock_mod.ino +++ b/examples/clock_mod/clock_mod.ino @@ -61,16 +61,16 @@ void setup() { gravity.Init(); // Clock handlers. - gravity.clock->AttachExtHandler(ExtClock); - gravity.clock->AttachIntHandler(IntClock); + gravity.clock.AttachExtHandler(ExtClock); + gravity.clock.AttachIntHandler(IntClock); // Encoder rotate and press handlers. - gravity.encoder->AttachPressHandler(HandleEncoderPressed); - gravity.encoder->AttachRotateHandler(HandleRotate); - gravity.encoder->AttachPressRotateHandler(HandlePressedRotate); + gravity.encoder.AttachPressHandler(HandleEncoderPressed); + gravity.encoder.AttachRotateHandler(HandleRotate); + gravity.encoder.AttachPressRotateHandler(HandlePressedRotate); // Button press handlers. - gravity.play_button->AttachPressHandler(HandlePlayPressed); + gravity.play_button.AttachPressHandler(HandlePlayPressed); } void loop() { @@ -88,8 +88,8 @@ void loop() { // void ExtClock() { - if (gravity.clock->ExternalSource()) { - gravity.clock->Tick(); + if (gravity.clock.ExternalSource()) { + gravity.clock.Tick(); app.refresh_screen = true; } } @@ -98,7 +98,7 @@ void IntClock(uint32_t tick) { for (int i = 0; i < OUTPUT_COUNT; i++) { const auto& channel = app.channel[i]; auto& output = gravity.outputs[i]; - + const uint32_t mod_pulses = clock_mod_pulses[channel.clock_mod_index]; const uint32_t current_tick_offset = tick + channel.offset_pulses; @@ -119,8 +119,8 @@ void IntClock(uint32_t tick) { } void HandlePlayPressed() { - gravity.clock->Pause(); - if (gravity.clock->IsPaused()) { + gravity.clock.Pause(); + if (gravity.clock.IsPaused()) { for (int i = 0; i < OUTPUT_COUNT; i++) { gravity.outputs[i].Low(); } @@ -149,21 +149,21 @@ void HandleRotate(Direction dir, int val) { if (app.selected_channel == 0) { switch (app.selected_param) { case 0: - if (gravity.clock->ExternalSource()) { + if (gravity.clock.ExternalSource()) { break; } - gravity.clock->SetTempo(gravity.clock->Tempo() + val); + gravity.clock.SetTempo(gravity.clock.Tempo() + val); app.refresh_screen = true; break; - case 1: + case 1: if (static_cast(app.selected_source) == 0 && val < 0) { app.selected_source = static_cast(SOURCE_LAST - 1); } else { app.selected_source = static_cast((app.selected_source + val) % SOURCE_LAST); } - gravity.clock->SetSource(app.selected_source); + gravity.clock.SetSource(app.selected_source); app.refresh_screen = true; break; } @@ -222,7 +222,7 @@ Channel& GetSelectedChannel() { void UpdateDisplay() { app.refresh_screen = false; - gravity.display->clearDisplay(); + gravity.display.clearDisplay(); if (app.selected_channel == 0) { DisplayMainPage(); @@ -231,82 +231,83 @@ void UpdateDisplay() { } // Show selected param indicator - gravity.display->drawChar(0, app.selected_param * 10, 0x10, 1, 0, 1); + gravity.display.drawChar(0, app.selected_param * 10, 0x10, 1, 0, 1); // Global channel select UI. DisplaySelectedChannel(); - gravity.display->display(); + gravity.display.display(); } void DisplaySelectedChannel() { - gravity.display->drawLine(1, 52, 126, 52, 1); + gravity.display.drawLine(1, 52, 126, 52, 1); for (int i = 0; i < 7; i++) { (app.selected_channel == i) - ? gravity.display->fillRect(i * 18, 52, 18, 12, 1) - : gravity.display->drawLine(i * 18, 52, i * 18, 64, 1); + ? gravity.display.fillRect(i * 18, 52, 18, 12, 1) + : gravity.display.drawLine(i * 18, 52, i * 18, 64, 1); int selected = app.selected_channel == i; if (i == 0) { - char icon = gravity.clock->IsPaused() ? CHAR_PAUSE : CHAR_PLAY; - gravity.display->drawChar((i * 18) + 7, 55, icon, !selected, selected, 1); + char icon = gravity.clock.IsPaused() ? CHAR_PAUSE : CHAR_PLAY; + gravity.display.drawChar((i * 18) + 7, 55, icon, !selected, selected, 1); } else { - gravity.display->drawChar((i * 18) + 7, 55, i + 48, !selected, selected, 1); + gravity.display.drawChar((i * 18) + 7, 55, i + 48, !selected, selected, 1); } } - gravity.display->drawLine(126, 52, 126, 64, 1); + gravity.display.drawLine(126, 52, 126, 64, 1); } void DisplayMainPage() { - gravity.display->setCursor(10, 0); - gravity.display->print(F("Tempo: ")); - gravity.display->print(gravity.clock->Tempo()); + gravity.display.setCursor(10, 0); + gravity.display.print(F("Tempo: ")); + gravity.display.print(gravity.clock.Tempo()); - gravity.display->setCursor(10, 10); - gravity.display->print(F("Source: ")); - switch (app.selected_source) { - case SOURCE_INTERNAL: - gravity.display->print(F("INT")); - break; - case SOURCE_EXTERNAL_PPQN_24: - gravity.display->print(F("EXT 24 PPQN")); - break; - case SOURCE_EXTERNAL_PPQN_4: - gravity.display->print(F("EXT 4 PPQN")); - break; - // case SOURCE_EXTERNAL_MIDI: - // gravity.display->print(F("EXT MIDI")); - // break; - default: - break; + gravity.display.setCursor(10, 10); + gravity.display.print(F("Source: ")); + switch (app.selected_source) + { + case SOURCE_INTERNAL: + gravity.display.print(F("INT")); + break; + case SOURCE_EXTERNAL_PPQN_24: + gravity.display.print(F("EXT 24 PPQN")); + break; + case SOURCE_EXTERNAL_PPQN_4: + gravity.display.print(F("EXT 4 PPQN")); + break; + // case SOURCE_EXTERNAL_MIDI: + // gravity.display.print(F("EXT MIDI")); + // break; + default: + break; } } void DisplayChannelPage() { auto& ch = GetSelectedChannel(); - gravity.display->setCursor(10, 0); - gravity.display->print(F("Mod: ")); + gravity.display.setCursor(10, 0); + gravity.display.print(F("Mod: ")); if (clock_mod[ch.clock_mod_index] > 1) { - gravity.display->print(F("/ ")); - gravity.display->print(clock_mod[ch.clock_mod_index]); + gravity.display.print(F("/ ")); + gravity.display.print(clock_mod[ch.clock_mod_index]); } else { - gravity.display->print(F("X ")); - gravity.display->print(abs(clock_mod[ch.clock_mod_index])); + gravity.display.print(F("X ")); + gravity.display.print(abs(clock_mod[ch.clock_mod_index])); } - gravity.display->setCursor(10, 10); - gravity.display->print(F("Probability: ")); - gravity.display->print(ch.probability); - gravity.display->print(F("%")); + gravity.display.setCursor(10, 10); + gravity.display.print(F("Probability: ")); + gravity.display.print(ch.probability); + gravity.display.print(F("%")); - gravity.display->setCursor(10, 20); - gravity.display->print(F("Duty Cycle: ")); - gravity.display->print(ch.duty_cycle); - gravity.display->print(F("%")); + gravity.display.setCursor(10, 20); + gravity.display.print(F("Duty Cycle: ")); + gravity.display.print(ch.duty_cycle); + gravity.display.print(F("%")); - gravity.display->setCursor(10, 30); - gravity.display->print(F("Offset: ")); - gravity.display->print(ch.offset); - gravity.display->print(F("%")); + gravity.display.setCursor(10, 30); + gravity.display.print(F("Offset: ")); + gravity.display.print(ch.offset); + gravity.display.print(F("%")); } \ No newline at end of file diff --git a/gravity.cpp b/gravity.cpp index 6ba4a36..2cfbb8b 100644 --- a/gravity.cpp +++ b/gravity.cpp @@ -19,22 +19,15 @@ void Gravity::Init() { } void Gravity::InitClock() { - clock = new Clock(); - clock->Init(); + clock.Init(); } void Gravity::InitInputs() { - shift_button = new Button(); - play_button = new Button(); - shift_button->Init(SHIFT_BTN_PIN); - play_button->Init(PLAY_BTN_PIN); + shift_button.Init(SHIFT_BTN_PIN); + play_button.Init(PLAY_BTN_PIN); - cv1 = new AnalogInput(); - cv2 = new AnalogInput(); - cv1->Init(CV1_PIN); - cv2->Init(CV2_PIN); - - encoder = new EncoderDir(); + cv1.Init(CV1_PIN); + cv2.Init(CV2_PIN); // Pin Change Interrupts for Encoder. // Thanks to https://dronebotworkshop.com/interrupts/ @@ -57,25 +50,23 @@ void Gravity::InitOutputs() { outputs[5].Init(OUT_CH6); } void Gravity::InitDisplay() { - display = new Adafruit_SSD1306(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); - // OLED Display configuration. - display->begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS); + display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS); delay(1000); - display->setRotation(2); // rotates text on OLED 1=90 degrees, 2=180 degrees - display->clearDisplay(); - display->setTextSize(1); - display->setTextColor(WHITE); - display->display(); + display.setRotation(2); // rotates text on OLED 1=90 degrees, 2=180 degrees + display.clearDisplay(); + display.setTextSize(1); + display.setTextColor(WHITE); + display.display(); } void Gravity::Process() { // Read peripherials for changes. - shift_button->Process(); - play_button->Process(); - encoder->Process(); - cv1->Process(); - cv2->Process(); + shift_button.Process(); + play_button.Process(); + encoder.Process(); + cv1.Process(); + cv2.Process(); // Update Output states. for (int i; i < OUTPUT_COUNT; i++) { @@ -84,7 +75,7 @@ void Gravity::Process() { } void ReadEncoder() { - gravity.encoder->UpdateEncoder(); + gravity.encoder.UpdateEncoder(); } // Define Encoder pin ISR. diff --git a/gravity.h b/gravity.h index 2d8909d..698ebcb 100644 --- a/gravity.h +++ b/gravity.h @@ -16,7 +16,8 @@ class Gravity { public: // Constructor - Gravity() {} + Gravity() + : display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1) {} // Deconstructor ~Gravity() {} @@ -27,14 +28,14 @@ class Gravity { // Polling check for state change of inputs and outputs. void Process(); - Adafruit_SSD1306 * display = nullptr; // OLED display object. - Clock * clock = nullptr; // Clock source wrapper. + Adafruit_SSD1306 display; // OLED display object. + Clock clock; // Clock source wrapper. DigitalOutput outputs[OUTPUT_COUNT]; // An array containing each Output object. - EncoderDir * encoder = nullptr; // Rotary encoder with button instance - Button * shift_button = nullptr; - Button * play_button = nullptr; - AnalogInput * cv1 = nullptr; - AnalogInput * cv2 = nullptr; + EncoderDir encoder; // Rotary encoder with button instance + Button shift_button; + Button play_button; + AnalogInput cv1; + AnalogInput cv2; private: void InitClock(); diff --git a/peripherials.h b/peripherials.h index 1b2abbb..5ec0539 100644 --- a/peripherials.h +++ b/peripherials.h @@ -15,7 +15,6 @@ #define OLED_ADDRESS 0x3C #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 -#define OLED_RESET -1 // Peripheral input pins #define ENCODER_PIN1 17 // A3 -- 2.39.5 From 20f65d9bdfb3ac2ed771b8fe75011abe113e6858 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sun, 1 Jun 2025 19:18:59 -0700 Subject: [PATCH 08/69] Full rewrite of graphics lib. Replace adafruit with u8g2. Overhaul UI. --- clock.h | 4 + examples/clock_mod/clock_mod.ino | 267 ++++++++++++++++++++++--------- gravity.cpp | 8 +- gravity.h | 13 +- 4 files changed, 205 insertions(+), 87 deletions(-) diff --git a/clock.h b/clock.h index 6578b1f..11de762 100644 --- a/clock.h +++ b/clock.h @@ -90,6 +90,10 @@ class Clock { uClock.pause(); } + void Reset() { + uClock.start(); + } + bool IsPaused() { return uClock.clock_state == uClock.PAUSED; } diff --git a/examples/clock_mod/clock_mod.ino b/examples/clock_mod/clock_mod.ino index fa6979a..fdb496d 100644 --- a/examples/clock_mod/clock_mod.ino +++ b/examples/clock_mod/clock_mod.ino @@ -13,7 +13,7 @@ * * BTN1: Play/pause the internal clock. * - * BTN2: Shift button for additional UI navigation (unused). + * BTN2: Reset all clocks. * */ @@ -24,7 +24,6 @@ struct Channel { byte clock_mod_index = 7; // x1 byte probability = 100; byte duty_cycle = 50; - // int duty_cycle_pulses = 12; // 120 x1 24 PPQN int duty_cycle_pulses = 48; // 120 x1 96 PPQN byte offset = 0; int offset_pulses = 0; @@ -43,14 +42,22 @@ const int MOD_CHOICE_SIZE = 21; // negative=multiply, positive=divide const int clock_mod[MOD_CHOICE_SIZE] = {-24, -12, -8, -6, -4, -3, -2, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 24, 32, 64, 128}; -// This represents the number of clock pulses for a 24 PPQN clock source that match the above div/mult mods. -// const int clock_mod_pulses[MOD_CHOICE_SIZE] = {1, 2, 3, 4, 6, 8, 12, 24, 48, 72, 96, 120, 144, 288, 168, 192, 384, 576, 768, 1536, 3072}; // LCM(322560) - // This represents the number of clock pulses for a 96 PPQN clock source that match the above div/mult mods. const int clock_mod_pulses[MOD_CHOICE_SIZE] = {4, 8, 12, 16, 24, 32, 48, 96, 192, 288, 384, 480, 576, 1152, 672, 768, 1536, 2304, 3072, 6144, 12288}; -const byte CHAR_PLAY = 0x10; -const byte CHAR_PAUSE = 0xB9; +const auto TEXT_FONT = u8g2_font_missingplanet_tr; +const auto LARGE_FONT = u8g2_font_maniac_tr; + +#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. @@ -71,6 +78,7 @@ void setup() { // Button press handlers. gravity.play_button.AttachPressHandler(HandlePlayPressed); + gravity.shift_button.AttachPressHandler(HandleShiftPressed); } void loop() { @@ -98,7 +106,7 @@ void IntClock(uint32_t tick) { for (int i = 0; i < OUTPUT_COUNT; i++) { const auto& channel = app.channel[i]; auto& output = gravity.outputs[i]; - + const uint32_t mod_pulses = clock_mod_pulses[channel.clock_mod_index]; const uint32_t current_tick_offset = tick + channel.offset_pulses; @@ -128,6 +136,10 @@ void HandlePlayPressed() { app.refresh_screen = true; } +void HandleShiftPressed() { + gravity.clock.Reset(); +} + void HandleEncoderPressed() { // TODO: make this more generic/dynamic @@ -156,7 +168,7 @@ void HandleRotate(Direction dir, int val) { app.refresh_screen = true; break; - case 1: + case 1: if (static_cast(app.selected_source) == 0 && val < 0) { app.selected_source = static_cast(SOURCE_LAST - 1); } else { @@ -222,92 +234,201 @@ Channel& GetSelectedChannel() { void UpdateDisplay() { app.refresh_screen = false; - gravity.display.clearDisplay(); - - if (app.selected_channel == 0) { - DisplayMainPage(); - } else { - DisplayChannelPage(); - } - - // Show selected param indicator - gravity.display.drawChar(0, app.selected_param * 10, 0x10, 1, 0, 1); - - // Global channel select UI. - DisplaySelectedChannel(); - - gravity.display.display(); + gravity.display.firstPage(); + do { + if (app.selected_channel == 0) { + DisplayMainPage(); + } else { + DisplayChannelPage(); + } + // Global channel select UI. + DisplaySelectedChannel(); + } while (gravity.display.nextPage()); } void DisplaySelectedChannel() { - gravity.display.drawLine(1, 52, 126, 52, 1); + int top = 50; + int boxWidth = 18; + int boxHeight = 14; + gravity.display.drawHLine(1, top, 126); for (int i = 0; i < 7; i++) { + gravity.display.setDrawColor(1); (app.selected_channel == i) - ? gravity.display.fillRect(i * 18, 52, 18, 12, 1) - : gravity.display.drawLine(i * 18, 52, i * 18, 64, 1); + ? gravity.display.drawBox(i * boxWidth, top, boxWidth, boxHeight) + : gravity.display.drawVLine(i * boxWidth, top, boxHeight); - int selected = app.selected_channel == i; + gravity.display.setDrawColor(2); if (i == 0) { - char icon = gravity.clock.IsPaused() ? CHAR_PAUSE : CHAR_PLAY; - gravity.display.drawChar((i * 18) + 7, 55, icon, !selected, selected, 1); + gravity.display.setDrawColor(2); + gravity.display.setBitmapMode(1); + auto icon = gravity.clock.IsPaused() ? pause_icon : play_icon; + gravity.display.drawXBM(2, top, play_icon_width, play_icon_height, icon); } else { - gravity.display.drawChar((i * 18) + 7, 55, i + 48, !selected, selected, 1); + gravity.display.setFont(TEXT_FONT); + gravity.display.setCursor((i * boxWidth) + 7, 63); + gravity.display.print(i); } } - gravity.display.drawLine(126, 52, 126, 64, 1); + gravity.display.drawVLine(126, top, boxHeight); } void DisplayMainPage() { - gravity.display.setCursor(10, 0); - gravity.display.print(F("Tempo: ")); - gravity.display.print(gravity.clock.Tempo()); + gravity.display.setFontMode(1); + gravity.display.setDrawColor(2); + gravity.display.setFont(TEXT_FONT); - gravity.display.setCursor(10, 10); - gravity.display.print(F("Source: ")); - switch (app.selected_source) - { - case SOURCE_INTERNAL: - gravity.display.print(F("INT")); - break; - case SOURCE_EXTERNAL_PPQN_24: - gravity.display.print(F("EXT 24 PPQN")); - break; - case SOURCE_EXTERNAL_PPQN_4: - gravity.display.print(F("EXT 4 PPQN")); - break; - // case SOURCE_EXTERNAL_MIDI: - // gravity.display.print(F("EXT MIDI")); - // break; - default: - break; + int textWidth; + int textY = 26; + int subTextY = 42; + + // Display selected editable value. + if (app.selected_param == 0) { + gravity.display.setFont(LARGE_FONT); + char num_str[3]; + sprintf(num_str, "%d", gravity.clock.Tempo()); + textWidth = gravity.display.getUTF8Width(num_str); + gravity.display.drawStr(32 - (textWidth / 2), textY, num_str); + gravity.display.setFont(TEXT_FONT); + textWidth = gravity.display.getUTF8Width("BPM"); + gravity.display.drawStr(32 - (textWidth / 2), subTextY, "BPM"); + } else if (app.selected_param == 1) { + switch (app.selected_source) { + case SOURCE_INTERNAL: + gravity.display.setFont(LARGE_FONT); + textWidth = gravity.display.getUTF8Width("INT"); + gravity.display.drawStr(32 - (textWidth / 2), textY, "INT"); + gravity.display.setFont(TEXT_FONT); + textWidth = gravity.display.getUTF8Width("Clock"); + gravity.display.drawStr(32 - (textWidth / 2), subTextY, "Clock"); + break; + case SOURCE_EXTERNAL_PPQN_24: + gravity.display.setFont(LARGE_FONT); + textWidth = gravity.display.getUTF8Width("EXT"); + gravity.display.drawStr(32 - (textWidth / 2), textY, "EXT"); + gravity.display.setFont(TEXT_FONT); + textWidth = gravity.display.getUTF8Width("24 PPQN"); + gravity.display.drawStr(32 - (textWidth / 2), subTextY, "24 PPQN"); + break; + case SOURCE_EXTERNAL_PPQN_4: + gravity.display.setFont(LARGE_FONT); + textWidth = gravity.display.getUTF8Width("EXT"); + gravity.display.drawStr(32 - (textWidth / 2), textY, "EXT"); + gravity.display.setFont(TEXT_FONT); + textWidth = gravity.display.getUTF8Width("4 PPQN"); + gravity.display.drawStr(32 - (textWidth / 2), subTextY, "4 PPQN"); + break; + // case SOURCE_EXTERNAL_MIDI: + // gravity.display.print(F("EXT MIDI")); + // break; + } } + + int idx; + int drawX; + int height = 14; + int padding = 4; + + // Draw selected menu item box. + gravity.display.drawBox(65, (height * app.selected_param) + 2, 63, height + 1); + + // Draw each menu item. + textWidth = gravity.display.getUTF8Width("Tempo"); + drawX = (SCREEN_WIDTH - textWidth) - padding; + gravity.display.drawStr(drawX, height * ++idx, "Tempo"); + + textWidth = gravity.display.getUTF8Width("Source"); + drawX = (SCREEN_WIDTH - textWidth) - padding; + gravity.display.drawStr(drawX, height * ++idx, "Source"); } void DisplayChannelPage() { auto& ch = GetSelectedChannel(); - gravity.display.setCursor(10, 0); - gravity.display.print(F("Mod: ")); - if (clock_mod[ch.clock_mod_index] > 1) { - gravity.display.print(F("/ ")); - gravity.display.print(clock_mod[ch.clock_mod_index]); - } else { - gravity.display.print(F("X ")); - gravity.display.print(abs(clock_mod[ch.clock_mod_index])); + gravity.display.setFontMode(1); + gravity.display.setDrawColor(2); + gravity.display.setFont(LARGE_FONT); + + int textWidth; + int textY = 26; + int subTextY = 42; + char num_str[4]; + + // Display selected editable value. + switch (app.selected_param) { + case 0: // Clock Mod + char mod_str[4]; + if (clock_mod[ch.clock_mod_index] > 1) { + sprintf(mod_str, "/%d", clock_mod[ch.clock_mod_index]); + textWidth = gravity.display.getUTF8Width(mod_str); + gravity.display.drawStr(32 - (textWidth / 2), textY, mod_str); + gravity.display.setFont(TEXT_FONT); + textWidth = gravity.display.getUTF8Width("Divide"); + gravity.display.drawStr(32 - (textWidth / 2), subTextY, "Divide"); + + } else { + sprintf(mod_str, "X%d", abs(clock_mod[ch.clock_mod_index])); + textWidth = gravity.display.getUTF8Width(mod_str); + gravity.display.drawStr(32 - (textWidth / 2), textY, mod_str); + gravity.display.setFont(TEXT_FONT); + textWidth = gravity.display.getUTF8Width("Multiply"); + gravity.display.drawStr(32 - (textWidth / 2), subTextY, "Multiply"); + } + break; + case 1: // Probability + sprintf(num_str, "%d%%", ch.probability); + textWidth = gravity.display.getUTF8Width(num_str); + gravity.display.drawStr(32 - (textWidth / 2), textY, num_str); + gravity.display.setFont(TEXT_FONT); + textWidth = gravity.display.getUTF8Width("Hit Chance"); + gravity.display.drawStr(32 - (textWidth / 2), subTextY, "Hit Chance"); + break; + case 2: // Duty Cycle + sprintf(num_str, "%d%%", ch.duty_cycle); + textWidth = gravity.display.getUTF8Width(num_str); + gravity.display.drawStr(32 - (textWidth / 2), textY, num_str); + gravity.display.setFont(TEXT_FONT); + textWidth = gravity.display.getUTF8Width("Pulse Width"); + gravity.display.drawStr(32 - (textWidth / 2), subTextY, "Pulse Width"); + break; + case 3: // Offset + sprintf(num_str, "%d%%", ch.offset); + textWidth = gravity.display.getUTF8Width(num_str); + gravity.display.drawStr(32 - (textWidth / 2), textY, num_str); + gravity.display.setFont(TEXT_FONT); + textWidth = gravity.display.getUTF8Width("Shift Hit"); + gravity.display.drawStr(32 - (textWidth / 2), subTextY, "Shift Hit"); + break; } - gravity.display.setCursor(10, 10); - gravity.display.print(F("Probability: ")); - gravity.display.print(ch.probability); - gravity.display.print(F("%")); + int idx = 0; + int drawX; + int height = 14; + int padding = 4; - gravity.display.setCursor(10, 20); - gravity.display.print(F("Duty Cycle: ")); - gravity.display.print(ch.duty_cycle); - gravity.display.print(F("%")); + gravity.display.setFont(TEXT_FONT); + int textHeight = gravity.display.getFontAscent(); - gravity.display.setCursor(10, 30); - gravity.display.print(F("Offset: ")); - gravity.display.print(ch.offset); - gravity.display.print(F("%")); + // Draw selected menu item box. + gravity.display.drawBox(65, (height * min(2, app.selected_param)) + 2, 63, height + 1); + + // Draw each menu item. + if (app.selected_param < 3) { + textWidth = gravity.display.getUTF8Width("Mod"); + drawX = (SCREEN_WIDTH - textWidth) - padding; + gravity.display.drawStr(drawX, (height * ++idx), "Mod"); + } + + textWidth = gravity.display.getUTF8Width("Probability"); + drawX = (SCREEN_WIDTH - textWidth) - padding; + gravity.display.drawStr(drawX, (height * ++idx), "Probability"); + + textWidth = gravity.display.getUTF8Width("Duty Cycle"); + drawX = (SCREEN_WIDTH - textWidth) - padding; + gravity.display.drawStr(drawX, (height * ++idx), "Duty Cycle"); + + if (app.selected_param > 2) { + textWidth = gravity.display.getUTF8Width("Offset"); + drawX = (SCREEN_WIDTH - textWidth) - padding; + gravity.display.drawStr(drawX, (height * ++idx), "Offset"); + } } \ No newline at end of file diff --git a/gravity.cpp b/gravity.cpp index 2cfbb8b..5c00f0a 100644 --- a/gravity.cpp +++ b/gravity.cpp @@ -51,13 +51,7 @@ void Gravity::InitOutputs() { } void Gravity::InitDisplay() { // OLED Display configuration. - display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS); - delay(1000); - display.setRotation(2); // rotates text on OLED 1=90 degrees, 2=180 degrees - display.clearDisplay(); - display.setTextSize(1); - display.setTextColor(WHITE); - display.display(); + display.begin(); } void Gravity::Process() { diff --git a/gravity.h b/gravity.h index 698ebcb..6bd4aed 100644 --- a/gravity.h +++ b/gravity.h @@ -1,9 +1,8 @@ #ifndef GRAVITY_H #define GRAVITY_H -#include -#include #include +#include #include "analog_input.h" #include "button.h" @@ -17,7 +16,7 @@ class Gravity { public: // Constructor Gravity() - : display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1) {} + : display(U8G2_R2, SCL, SDA, U8X8_PIN_NONE) {} // Deconstructor ~Gravity() {} @@ -28,10 +27,10 @@ class Gravity { // Polling check for state change of inputs and outputs. void Process(); - Adafruit_SSD1306 display; // OLED display object. - Clock clock; // Clock source wrapper. - DigitalOutput outputs[OUTPUT_COUNT]; // An array containing each Output object. - EncoderDir encoder; // Rotary encoder with button instance + U8G2_SSD1306_128X64_NONAME_2_HW_I2C display; // OLED display object. + Clock clock; // Clock source wrapper. + DigitalOutput outputs[OUTPUT_COUNT]; // An array containing each Output object. + EncoderDir encoder; // Rotary encoder with button instance Button shift_button; Button play_button; AnalogInput cv1; -- 2.39.5 From 8cf64fefca5fafa886772454b008d5d4f9e48568 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sun, 1 Jun 2025 22:53:51 -0700 Subject: [PATCH 09/69] Initial commit for midi. Midi out is working, Midi In is still a work in progress. --- clock.h | 65 +++++++++++++++++++++++++++++++- examples/clock_mod/clock_mod.ino | 15 +++++--- peripherials.h | 1 + 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/clock.h b/clock.h index 11de762..c73c0b1 100644 --- a/clock.h +++ b/clock.h @@ -12,28 +12,49 @@ #ifndef CLOCK_H #define CLOCK_H +#include #include #include "peripherials.h" +// 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 +#define MIDI_CONTINUE 0xFB + const int DEFAULT_TEMPO = 120; +static bool MIDI_ENABLED = false; enum Source { SOURCE_INTERNAL, SOURCE_EXTERNAL_PPQN_24, SOURCE_EXTERNAL_PPQN_4, - // SOURCE_MIDI, + SOURCE_EXTERNAL_MIDI, SOURCE_LAST, }; class Clock { public: void Init() { + NeoSerial.begin(31250); + NeoSerial.attachInterrupt(onSerialEvent); + + // Static pin definition for pulse out. + pinMode(PULSE_OUT_PIN, OUTPUT); + // 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); + uClock.setOnSync48(sendPulseOut); + uClock.start(); } @@ -50,6 +71,7 @@ class Clock { // Set the source of the clock mode. void SetSource(Source source) { uClock.stop(); + MIDI_ENABLED = false; switch (source) { case SOURCE_INTERNAL: uClock.setClockMode(uClock.INTERNAL_CLOCK); @@ -62,6 +84,11 @@ class Clock { 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); + MIDI_ENABLED = true; + break; } uClock.start(); } @@ -97,6 +124,42 @@ class Clock { bool IsPaused() { return uClock.clock_state == uClock.PAUSED; } + + private: + static void onSerialEvent(uint8_t msg, uint8_t status) { + // if (!MIDI_ENABLED) { + // return; + // } + switch (msg) { + case MIDI_CLOCK: + uClock.clockMe(); + break; + case MIDI_STOP: + uClock.stop(); + break; + case MIDI_START: + case MIDI_CONTINUE: + uClock.start(); + 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 sendPulseOut(uint32_t tick) { + digitalWrite(PULSE_OUT_PIN, !digitalRead(PULSE_OUT_PIN)); + } }; #endif \ No newline at end of file diff --git a/examples/clock_mod/clock_mod.ino b/examples/clock_mod/clock_mod.ino index fdb496d..9fd4f65 100644 --- a/examples/clock_mod/clock_mod.ino +++ b/examples/clock_mod/clock_mod.ino @@ -39,7 +39,7 @@ AppState app; // The number of clock mod options, hepls validate choices and pulses arrays are the same size. const int MOD_CHOICE_SIZE = 21; -// negative=multiply, positive=divide +// Negative for multiply, positive for divide. const int clock_mod[MOD_CHOICE_SIZE] = {-24, -12, -8, -6, -4, -3, -2, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 24, 32, 64, 128}; // This represents the number of clock pulses for a 96 PPQN clock source that match the above div/mult mods. @@ -317,9 +317,14 @@ void DisplayMainPage() { textWidth = gravity.display.getUTF8Width("4 PPQN"); gravity.display.drawStr(32 - (textWidth / 2), subTextY, "4 PPQN"); break; - // case SOURCE_EXTERNAL_MIDI: - // gravity.display.print(F("EXT MIDI")); - // break; + case SOURCE_EXTERNAL_MIDI: + gravity.display.setFont(LARGE_FONT); + textWidth = gravity.display.getUTF8Width("EXT"); + gravity.display.drawStr(32 - (textWidth / 2), textY, "EXT"); + gravity.display.setFont(TEXT_FONT); + textWidth = gravity.display.getUTF8Width("MIDI"); + gravity.display.drawStr(32 - (textWidth / 2), subTextY, "MIDI"); + break; } } @@ -366,7 +371,7 @@ void DisplayChannelPage() { gravity.display.drawStr(32 - (textWidth / 2), subTextY, "Divide"); } else { - sprintf(mod_str, "X%d", abs(clock_mod[ch.clock_mod_index])); + sprintf(mod_str, "x%d", abs(clock_mod[ch.clock_mod_index])); textWidth = gravity.display.getUTF8Width(mod_str); gravity.display.drawStr(32 - (textWidth / 2), textY, mod_str); gravity.display.setFont(TEXT_FONT); diff --git a/peripherials.h b/peripherials.h index 5ec0539..1864462 100644 --- a/peripherials.h +++ b/peripherials.h @@ -25,6 +25,7 @@ #define EXT_PIN 2 #define CV1_PIN A7 #define CV2_PIN A6 +#define PULSE_OUT_PIN 3 // Button pins #define SHIFT_BTN_PIN 12 -- 2.39.5 From 01bf09d4f5f1cf9d984a5029d604a8f908454db5 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Mon, 2 Jun 2025 21:56:17 -0700 Subject: [PATCH 10/69] Consistent clock behavior for ext cv and midi. --- clock.h | 58 +++++++++++++++++++++----------- examples/clock_mod/clock_mod.ino | 48 +++++++++++++++----------- gravity.cpp | 16 ++++----- gravity.h | 8 ++--- 4 files changed, 79 insertions(+), 51 deletions(-) diff --git a/clock.h b/clock.h index c73c0b1..44eac80 100644 --- a/clock.h +++ b/clock.h @@ -24,7 +24,10 @@ #define MIDI_CONTINUE 0xFB const int DEFAULT_TEMPO = 120; -static bool MIDI_ENABLED = false; + +typedef void (*ExtCallback)(void); +static ExtCallback extUserCallback = nullptr; +static void serialEventNoop(uint8_t msg, uint8_t status) {} enum Source { SOURCE_INTERNAL, @@ -38,12 +41,12 @@ class Clock { public: void Init() { NeoSerial.begin(31250); - NeoSerial.attachInterrupt(onSerialEvent); // Static pin definition for pulse out. pinMode(PULSE_OUT_PIN, OUTPUT); // Initialize the clock library + uClock.setExtIntervalBuffer(32); uClock.init(); uClock.setClockMode(uClock.INTERNAL_CLOCK); uClock.setOutputPPQN(uClock.PPQN_96); @@ -52,14 +55,15 @@ class Clock { // MIDI events. uClock.setOnClockStart(sendMIDIStart); uClock.setOnClockStop(sendMIDIStop); - uClock.setOnSync24(sendMidiClock); + uClock.setOnSync24(sendMIDIClock); uClock.setOnSync48(sendPulseOut); uClock.start(); } - // Handler for receiving clock trigger(PPQN_4 or PPQN_24). - void AttachExtHandler(void (*callback)(void)) { + // 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); } @@ -70,8 +74,13 @@ class Clock { // Set the source of the clock mode. void SetSource(Source source) { + bool was_playing = !IsPaused(); uClock.stop(); - MIDI_ENABLED = false; + // If source is currently 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); @@ -87,52 +96,64 @@ class Clock { case SOURCE_EXTERNAL_MIDI: uClock.setClockMode(uClock.EXTERNAL_CLOCK); uClock.setInputPPQN(uClock.PPQN_24); - MIDI_ENABLED = true; + NeoSerial.attachInterrupt(onSerialEvent); break; } - uClock.start(); + 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(); } - void Pause() { - uClock.pause(); - } - - void Reset() { + // Start the internal clock. + void Start() { uClock.start(); } + // Stop internal clock clock. + void Stop() { + uClock.stop(); + } + + // 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) { - // if (!MIDI_ENABLED) { - // return; - // } + // Note: uClock start and stop will echo to MIDI. switch (msg) { case MIDI_CLOCK: - uClock.clockMe(); + if (extUserCallback) { + extUserCallback(); + } break; case MIDI_STOP: uClock.stop(); @@ -141,7 +162,6 @@ class Clock { case MIDI_CONTINUE: uClock.start(); break; - } } @@ -153,7 +173,7 @@ class Clock { NeoSerial.write(MIDI_STOP); } - static void sendMidiClock(uint32_t tick) { + static void sendMIDIClock(uint32_t tick) { NeoSerial.write(MIDI_CLOCK); } diff --git a/examples/clock_mod/clock_mod.ino b/examples/clock_mod/clock_mod.ino index 9fd4f65..e2823be 100644 --- a/examples/clock_mod/clock_mod.ino +++ b/examples/clock_mod/clock_mod.ino @@ -13,7 +13,7 @@ * * BTN1: Play/pause the internal clock. * - * BTN2: Reset all clocks. + * BTN2: Stop all clocks. * */ @@ -68,8 +68,8 @@ void setup() { gravity.Init(); // Clock handlers. - gravity.clock.AttachExtHandler(ExtClock); - gravity.clock.AttachIntHandler(IntClock); + gravity.clock.AttachIntHandler(HandleIntClockTick); + gravity.clock.AttachExtHandler(HandleExtClockTick); // Encoder rotate and press handlers. gravity.encoder.AttachPressHandler(HandleEncoderPressed); @@ -95,16 +95,9 @@ void loop() { // Firmware handlers. // -void ExtClock() { - if (gravity.clock.ExternalSource()) { - gravity.clock.Tick(); - app.refresh_screen = true; - } -} - -void IntClock(uint32_t tick) { +void HandleIntClockTick(uint32_t tick) { for (int i = 0; i < OUTPUT_COUNT; i++) { - const auto& channel = app.channel[i]; + auto& channel = app.channel[i]; auto& output = gravity.outputs[i]; const uint32_t mod_pulses = clock_mod_pulses[channel.clock_mod_index]; @@ -126,18 +119,27 @@ void IntClock(uint32_t tick) { } } -void HandlePlayPressed() { - gravity.clock.Pause(); - if (gravity.clock.IsPaused()) { - for (int i = 0; i < OUTPUT_COUNT; i++) { - gravity.outputs[i].Low(); - } +void HandleExtClockTick() { + // Ignore tick if not using external source. + if (!gravity.clock.ExternalSource()) { + return; } + gravity.clock.Tick(); + app.refresh_screen = true; +} + +void HandlePlayPressed() { + gravity.clock.IsPaused() + ? gravity.clock.Start() + : gravity.clock.Stop(); + ResetOutputs(); app.refresh_screen = true; } void HandleShiftPressed() { - gravity.clock.Reset(); + gravity.clock.Stop(); + ResetOutputs(); + app.refresh_screen = true; } void HandleEncoderPressed() { @@ -228,6 +230,12 @@ Channel& GetSelectedChannel() { return app.channel[app.selected_channel - 1]; } +void ResetOutputs() { + for (int i = 0; i < OUTPUT_COUNT; i++) { + gravity.outputs[i].Low(); + } +} + // // UI Display functions. // @@ -352,7 +360,7 @@ void DisplayChannelPage() { gravity.display.setFontMode(1); gravity.display.setDrawColor(2); gravity.display.setFont(LARGE_FONT); - + int textWidth; int textY = 26; int subTextY = 42; diff --git a/gravity.cpp b/gravity.cpp index 5c00f0a..00efe16 100644 --- a/gravity.cpp +++ b/gravity.cpp @@ -12,17 +12,17 @@ #include "gravity.h" void Gravity::Init() { - InitClock(); - InitInputs(); - InitOutputs(); - InitDisplay(); + initClock(); + initInputs(); + initOutputs(); + initDisplay(); } -void Gravity::InitClock() { +void Gravity::initClock() { clock.Init(); } -void Gravity::InitInputs() { +void Gravity::initInputs() { shift_button.Init(SHIFT_BTN_PIN); play_button.Init(PLAY_BTN_PIN); @@ -40,7 +40,7 @@ void Gravity::InitInputs() { PCMSK1 |= B00001000; } -void Gravity::InitOutputs() { +void Gravity::initOutputs() { // Initialize each of the outputs with it's GPIO pins and probability. outputs[0].Init(OUT_CH1); outputs[1].Init(OUT_CH2); @@ -49,7 +49,7 @@ void Gravity::InitOutputs() { outputs[4].Init(OUT_CH5); outputs[5].Init(OUT_CH6); } -void Gravity::InitDisplay() { +void Gravity::initDisplay() { // OLED Display configuration. display.begin(); } diff --git a/gravity.h b/gravity.h index 6bd4aed..9bba82d 100644 --- a/gravity.h +++ b/gravity.h @@ -37,10 +37,10 @@ class Gravity { AnalogInput cv2; private: - void InitClock(); - void InitDisplay(); - void InitInputs(); - void InitOutputs(); + void initClock(); + void initDisplay(); + void initInputs(); + void initOutputs(); }; extern Gravity gravity; -- 2.39.5 From c1b29924f71d9cea8c3a9d601c123760b1471396 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Tue, 3 Jun 2025 07:33:50 -0700 Subject: [PATCH 11/69] Do not show MIDI tempo. Serial MIDI is too unstable. CV ext seems pretty stable when given a steady clock, but I need to try unstable sources / polyrhythms. --- clock.h | 1 - examples/clock_mod/clock_mod.ino | 7 ++++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/clock.h b/clock.h index 44eac80..2a0e54b 100644 --- a/clock.h +++ b/clock.h @@ -46,7 +46,6 @@ class Clock { pinMode(PULSE_OUT_PIN, OUTPUT); // Initialize the clock library - uClock.setExtIntervalBuffer(32); uClock.init(); uClock.setClockMode(uClock.INTERNAL_CLOCK); uClock.setOutputPPQN(uClock.PPQN_96); diff --git a/examples/clock_mod/clock_mod.ino b/examples/clock_mod/clock_mod.ino index e2823be..0474477 100644 --- a/examples/clock_mod/clock_mod.ino +++ b/examples/clock_mod/clock_mod.ino @@ -293,7 +293,12 @@ void DisplayMainPage() { if (app.selected_param == 0) { gravity.display.setFont(LARGE_FONT); char num_str[3]; - sprintf(num_str, "%d", gravity.clock.Tempo()); + // Serial MIID is too unstable to display bpm in real time. + if (app.selected_source == SOURCE_EXTERNAL_MIDI) { + sprintf(num_str, "%s", "EXT"); + } else { + sprintf(num_str, "%d", gravity.clock.Tempo()); + } textWidth = gravity.display.getUTF8Width(num_str); gravity.display.drawStr(32 - (textWidth / 2), textY, num_str); gravity.display.setFont(TEXT_FONT); -- 2.39.5 From d9106c6951e63a2b6a4a884e5f91a5feca7af51d Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sun, 8 Jun 2025 11:39:12 -0700 Subject: [PATCH 12/69] refactor calibrate analog demo to use tge u8g2 library. --- .../calibrate_analog/calibrate_analog.ino | 63 ++++++++++++------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/examples/calibrate_analog/calibrate_analog.ino b/examples/calibrate_analog/calibrate_analog.ino index 4dc1e2a..c3e6a5f 100644 --- a/examples/calibrate_analog/calibrate_analog.ino +++ b/examples/calibrate_analog/calibrate_analog.ino @@ -19,6 +19,10 @@ #include "gravity.h" +#define TEXT_FONT u8g2_font_profont11_tf +#define INDICATOR_FONT u8g2_font_open_iconic_arrow_1x_t +#define U8G2_UP_ARROW_GLYPH 0x43 // caret-up 'Ë„' + byte selected_param = 0; // Initialize the gravity library and attach your handlers in the setup method. @@ -46,7 +50,7 @@ void CalibrateCV(Direction dir, int val) { cv->AdjustCalibrationLow(val); break; case 1: - cv->AdjustCalibrationOffset(val); + cv->SetOffset(val); break; case 2: cv->AdjustCalibrationHigh(val); @@ -55,51 +59,64 @@ void CalibrateCV(Direction dir, int val) { } void UpdateDisplay() { - gravity.display.clearDisplay(); - DisplayCalibration(&gravity.cv1, "CV1: ", 0); - DisplayCalibration(&gravity.cv2, "CV2: ", 1); - gravity.display.display(); + gravity.display.firstPage(); + do { + // Set default font mode and color for each page draw + gravity.display.setFontMode(0); // Transparent font background + gravity.display.setDrawColor(1); // Draw with color 1 (ON) + gravity.display.setFont(TEXT_FONT); + DisplayCalibration(&gravity.cv1, "CV1: ", 0); + DisplayCalibration(&gravity.cv2, "CV2: ", 1); + } while (gravity.display.nextPage()); + } -void DisplayCalibration(AnalogInput* cv, String title, int index) { +void DisplayCalibration(AnalogInput* cv, const char* title, int index) { + int barWidth = 100; int barHeight = 10; int textHeight = 10; int half = barWidth / 2; int offsetX = 16; int offsetY = (32 * index); - int color = 1; + + // U8g2 draw color: 1 for foreground (white/on), 0 for background (black/off) + gravity.display.setDrawColor(1); // CV value reading. - int value = constrain(cv->Read(), -512, 512); + int value = cv->Read(); - gravity.display.setCursor(0, offsetY); + // Set cursor and print title and value + gravity.display.setCursor(0, offsetY + textHeight); // Adjust y-position to align with text base line gravity.display.print(title); - gravity.display.print(value >= 0 ? " " : ""); + if (value >= 0) gravity.display.print(" "); // Add space for positive values for alignment gravity.display.print(value); - gravity.display.setCursor(92, offsetY); - gravity.display.print(value >= 0 ? " " : ""); - gravity.display.print(cv->Voltage()); + // Print voltage + gravity.display.setCursor(92, offsetY + textHeight); // Adjust x,y position + if (cv->Voltage() >= 0) gravity.display.print(" "); + gravity.display.print(cv->Voltage(), 1); // Print float with 1 decimal place gravity.display.print(F("V")); - gravity.display.drawRect(offsetX, textHeight + offsetY, barWidth, barHeight, color); + // Draw the main bar rectangle + gravity.display.drawFrame(offsetX, textHeight + offsetY + 2, barWidth, barHeight); // Using drawFrame instead of drawRect + if (value > 0) { // 0 to 512 - int x = (float(value) / 512.0) * half; - int fill = min(x, 512); - gravity.display.fillRect(half + offsetX, textHeight + offsetY, fill, barHeight, color); + int x = map(value, 0, 512, 0, half); // Map value to bar fill width + gravity.display.drawBox(half + offsetX, textHeight + offsetY + 2, x, barHeight); // Using drawBox instead of fillRect } else { // -512 to 0 - int x = (float(abs(value)) / 512.0) * half; - int fill = min(half, x); - gravity.display.fillRect((half + offsetX) - x, textHeight + offsetY, fill, barHeight, color); + int x = map(abs(value), 0, 512, 0, half); // Map absolute value to bar fill width + gravity.display.drawBox((half + offsetX) - x, textHeight + offsetY + 2, x, barHeight); // Using drawBox } // Display selected calibration point if selected calibration point belongs to current cv input. if (selected_param / 3 == index) { - int left = offsetX + ((half - 2) * (selected_param % 3)); - int top = barHeight + textHeight + offsetY + 2; - gravity.display.drawChar(left, top, 0x1E, 1, 0, 1); + int charWidth = 6; + int left = offsetX + (half * (selected_param % 3) - 2); // Adjust position + int top = barHeight + textHeight + offsetY + 12; + // Drawing an arrow character (ASCII 0x1E is often an up arrow in some fonts, might need adjustment) + gravity.display.drawStr(left, top, "^"); // Draw the arrow character } } -- 2.39.5 From 1a13fbff5f6d1125bfb6f62fbacd6240f1c16e91 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sun, 8 Jun 2025 11:39:27 -0700 Subject: [PATCH 13/69] Introduce a new demo sketch to visulize the attenuation and offset constraints of the input cv. --- analog_input.h | 45 +++-- .../calibrate_analog2/calibrate_analog2.ino | 171 ++++++++++++++++++ 2 files changed, 201 insertions(+), 15 deletions(-) create mode 100644 examples/calibrate_analog2/calibrate_analog2.ino diff --git a/analog_input.h b/analog_input.h index fb44018..7838653 100644 --- a/analog_input.h +++ b/analog_input.h @@ -4,25 +4,29 @@ * @brief Class for interacting with analog inputs. * @version 0.1 * @date 2025-05-23 - * + * * @copyright Copyright (c) 2025 - * + * */ #ifndef ANALOG_INPUT_H #define ANALOG_INPUT_H 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() {} /** - * Initializes a analog input object. - * - * @param pin gpio pin for the analog input. - */ + * Initializes a analog input object. + * + * @param pin gpio pin for the analog input. + */ void Init(uint8_t pin) { pinMode(pin, INPUT); pin_ = pin; @@ -30,33 +34,43 @@ class AnalogInput { /** * Read the value of the analog input and set instance state. - * + * */ void Process() { old_read_ = read_; int raw = analogRead(pin_); - read_ = map(raw, offset_, MAX_INPUT, low_, high_); + read_ = map(raw, 0, MAX_INPUT, low_, high_); + read_ = constrain(read_ - offset_, -512, 512); + if (inverted_) read_ = -read_; } // Set calibration values. void AdjustCalibrationLow(int amount) { low_ += amount; } - void AdjustCalibrationOffset(int amount) { offset_ -= amount; } + void AdjustCalibrationHigh(int amount) { high_ += amount; } + void SetOffset(float percent) { offset_ = -(percent)*512; } + + void SetAttenuation(float percent) { + low_ = abs(percent) * CALIBRATED_LOW; + high_ = abs(percent) * CALIBRATED_HIGH; + inverted_ = percent < 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_; } /** * 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); } @@ -66,8 +80,9 @@ class AnalogInput { uint16_t old_read_; // calibration values. int offset_ = 0; - int low_ = -512; - int high_ = 512; + int low_ = CALIBRATED_LOW; + int high_ = CALIBRATED_HIGH; + bool inverted_ = false; }; #endif diff --git a/examples/calibrate_analog2/calibrate_analog2.ino b/examples/calibrate_analog2/calibrate_analog2.ino new file mode 100644 index 0000000..ac5d772 --- /dev/null +++ b/examples/calibrate_analog2/calibrate_analog2.ino @@ -0,0 +1,171 @@ +/** + * Analog Input Attenuate and Offset Script + * + * This script provides a demonstration of representing cv input on a 270 + * degree arc (similar to the range of a potentiometer) with 12 o'clock + * representing 0v, ~5 o'clock representing 5v and ~7 o'clock representing 0v. + * + * The input voltage can be attenuated/attenuverted and offset. The arc will + * shrink and rotate according to the attenuate/offset values and the input cv + * will be displayed within the configured boundaries. + * + * Note: drawing an arc is expensive and there are a lot of arcs in this + * sketch, so the refresh rate is pretty slow. + * + */ + +#include "gravity.h" + +#define TEXT_FONT u8g2_font_profont11_tf + +byte selected_param = 0; +int offset = 0; +int attenuate = 100; + +// Initialize the gravity library and attach your handlers in the setup method. +void setup() { + gravity.Init(); + gravity.encoder.AttachRotateHandler(CalibrateCV); + gravity.encoder.AttachPressHandler(NextCalibrationPoint); +} + +// The loop method must always call `gravity.Process()` to read any peripherial state changes. +void loop() { + gravity.Process(); + UpdateDisplay(); +} + +void NextCalibrationPoint() { + selected_param = (selected_param + 1) % 2; +} + +void CalibrateCV(Direction dir, int val) { + // AnalogInput* cv = (selected_param > 2) ? &gravity.cv2 : &gravity.cv1; + AnalogInput* cv = &gravity.cv1; + switch (selected_param % 2) { + case 0: + attenuate = constrain(attenuate + val, -100, 100); + cv->SetAttenuation(float(attenuate) / 100.0f); + break; + case 1: + offset = constrain(offset + val, -100, 100); + cv->SetOffset(float(offset) / 100.0f); + break; + } +} + +void UpdateDisplay() { + gravity.display.firstPage(); + do { + // Set default font mode and color for each page draw + gravity.display.setFontMode(0); // Transparent font background + gravity.display.setDrawColor(1); // Draw with color 1 (ON) + gravity.display.setFont(TEXT_FONT); + + DisplayCalibrationArc(&gravity.cv1, 0); + } while (gravity.display.nextPage()); +} + +void DisplayCalibrationArc(AnalogInput* cv, int index) { + gravity.display.setFont(TEXT_FONT); + int text_ascent = gravity.display.getAscent(); + + int inputValue = cv->Read(); + + // Print param label, param value, and internal value. + gravity.display.setCursor(0, 64); + switch (selected_param) { + case 0: + gravity.display.print("attenuate: "); + if (attenuate >= 0) gravity.display.print(" "); + gravity.display.print(attenuate); + gravity.display.print("%"); + break; + case 1: + gravity.display.print("offset: "); + if (offset >= 0) gravity.display.print(" "); + gravity.display.print(offset); + gravity.display.print("%"); + break; + } + gravity.display.setCursor(100, 64); + if (inputValue >= 0) gravity.display.print(" "); + gravity.display.print(inputValue); + + // If attenuate is 0, do not draw arc. + if (attenuate == 0) { + return; + } + + // Arc drawing parameters. + const int arc_cx = 64; + const int arc_cy = 32; + const int outter_radius = 28; + const int inner_radius = 12; + const int arc_dist = outter_radius - inner_radius; // (28 - 12) = 16 + const int arc_north = 64; // Approx (90.0 / 360.0) * 255.0 + const int half_arc = 96; // Approx (135.0 / 360.0) * 255.0 + const int max_start = 223; // map(360-45, 0, 360, 0, 255); // 315 -> 223 + const int max_end = 159; // map(270-45, 0, 360, 0, 255); // 225 -> 159 + int start = max_start; + int end = max_end; + + // Modify the cv arc frame start/end according to the attenuate/offset values. + if (attenuate != 100) { + float attenuation_factor = abs(float(attenuate) / 100.0f); + int attenuate_amount = round((float)half_arc * (1.0f - attenuation_factor)); + start += attenuate_amount; + end -= attenuate_amount; + } + if (offset != 0) { + float offset_factor = float(offset) / 100.0f; + int offset_amount = round((float)(half_arc) * (offset_factor)); + // check attenuation if the offset should be flipped. + if (attenuate > 0) { + start = max(start - offset_amount, max_start); + end = min(end - offset_amount, max_end); + } else { + start = max(start + offset_amount, max_start); + end = min(end + offset_amount, max_end); + } + } + + // Draw the cv arc frame and end cap lines. + gravity.display.drawArc(arc_cx, arc_cy, outter_radius, start, end); + gravity.display.drawArc(arc_cx, arc_cy, inner_radius, start, end); + // Use drawArc to draw lines connecting the ends of the arc to close the frame. + for (int i = 0; i < arc_dist; i++) { + gravity.display.drawArc(arc_cx, arc_cy, inner_radius + i, start, start + 1); + gravity.display.drawArc(arc_cx, arc_cy, inner_radius + i, end, end + 1); + } + + int fill_arc_start; + int fill_arc_end; + + if (inputValue >= 0) { + // For positive values (0 to 512), fill clockwise from North + // map inputValue (0 to 512) to angle_offset_units (0 to half_arc) + long mapped_angle_offset = map(inputValue, 0, 512, 0, half_arc); + + fill_arc_start = (arc_north - mapped_angle_offset + 256) % 256; + fill_arc_end = arc_north + 1; + } else { // Negative values (-512 to -1) + // For negative values, fill counter-clockwise from North + long mapped_angle_offset = map(abs(inputValue), 0, 512, 0, half_arc); // abs(inputValue) is 1 to 512 + + fill_arc_start = arc_north - 1; + fill_arc_end = (arc_north + mapped_angle_offset) % 256; + } + + // Draw the filled portion of the arc by drawing multiple concentric arcs + // The step for 'i' determines the density of the fill. + // i+=1 for solid fill, i+=4 for a coarser, faster fill. + int fill_step = 4; + + // Only draw if there's an actual arc segment to fill (inputValue != 0) + if (inputValue != 0) { + for (int i = fill_step; i < arc_dist - 1; i += fill_step) { + gravity.display.drawArc(arc_cx, arc_cy, inner_radius + i, fill_arc_start, fill_arc_end); + } + } +} -- 2.39.5 From dac1bb300772ed3f0807e3ff356b20ab472d4a69 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sun, 8 Jun 2025 18:17:19 -0700 Subject: [PATCH 14/69] Big refactor of code for readability and reusability. Mostly focusing on the UI code. --- examples/clock_mod/clock_mod.ino | 415 ++++++++++++++++--------------- 1 file changed, 215 insertions(+), 200 deletions(-) diff --git a/examples/clock_mod/clock_mod.ino b/examples/clock_mod/clock_mod.ino index 0474477..3f3b65b 100644 --- a/examples/clock_mod/clock_mod.ino +++ b/examples/clock_mod/clock_mod.ino @@ -30,6 +30,7 @@ struct Channel { }; struct AppState { bool refresh_screen = true; + bool editing_param = false; byte selected_param = 0; byte selected_channel = 0; // 0=tempo, 1-6=output channel Source selected_source = SOURCE_INTERNAL; @@ -37,6 +38,19 @@ struct AppState { }; AppState app; +enum ParamsMainPage { + PARAM_MAIN_TEMPO, + PARAM_MAIN_SOURCE, + PARAM_MAIN_LAST, +}; +enum ParamsChannelPage { + PARAM_CH_MOD, + PARAM_CH_PROB, + PARAM_CH_DUTY, + PARAM_CH_OFFSET, + PARAM_CH_LAST, +}; + // The number of clock mod options, hepls validate choices and pulses arrays are the same size. const int MOD_CHOICE_SIZE = 21; // Negative for multiply, positive for divide. @@ -92,7 +106,7 @@ void loop() { } // -// Firmware handlers. +// Firmware handlers for clocks. // void HandleIntClockTick(uint32_t tick) { @@ -128,9 +142,13 @@ void HandleExtClockTick() { app.refresh_screen = true; } +// +// UI handlers for encoder and buttons. +// + void HandlePlayPressed() { - gravity.clock.IsPaused() - ? gravity.clock.Start() + gravity.clock.IsPaused() + ? gravity.clock.Start() : gravity.clock.Stop(); ResetOutputs(); app.refresh_screen = true; @@ -143,73 +161,83 @@ void HandleShiftPressed() { } void HandleEncoderPressed() { - // TODO: make this more generic/dynamic - - // Main Global Settings Page. - if (app.selected_channel == 0) { - app.selected_param = (app.selected_param + 1) % 2; - } - // Selected Output Channels 1-6 Settings. - else { - app.selected_param = (app.selected_param + 1) % 4; - } + app.editing_param = !app.editing_param; app.refresh_screen = true; } void HandleRotate(Direction dir, int val) { - // Execute the behavior of the current selected parameter. - - // Main Global Settings Page. - if (app.selected_channel == 0) { - switch (app.selected_param) { - case 0: - if (gravity.clock.ExternalSource()) { - break; - } - gravity.clock.SetTempo(gravity.clock.Tempo() + val); - app.refresh_screen = true; - break; - - case 1: - if (static_cast(app.selected_source) == 0 && val < 0) { - app.selected_source = static_cast(SOURCE_LAST - 1); - } else { - app.selected_source = static_cast((app.selected_source + val) % SOURCE_LAST); - } - - gravity.clock.SetSource(app.selected_source); - app.refresh_screen = true; - break; + // Select a prameter when not in edit mode. + if (!app.editing_param) { + // Main Global Settings Page. + if (app.selected_channel == 0) { + if (app.selected_param == 0 && val < 0) { + app.selected_param = PARAM_MAIN_LAST - 1; + } else { + app.selected_param = (app.selected_param + val) % PARAM_MAIN_LAST; + } + } + // Selected Output Channels 1-6 Settings. + else { + if (app.selected_param == 0 && val < 0) { + app.selected_param = PARAM_CH_LAST - 1; + } else { + app.selected_param = (app.selected_param + val) % PARAM_CH_LAST; + } } } - // Selected Output Channel Settings. + // Edit selected param. else { - auto& ch = GetSelectedChannel(); + // Main Global Settings Page. + if (app.selected_channel == 0) { + switch (static_cast(app.selected_param)) { + case PARAM_MAIN_TEMPO: + if (gravity.clock.ExternalSource()) { + break; + } + gravity.clock.SetTempo(gravity.clock.Tempo() + val); + app.refresh_screen = true; + break; - switch (app.selected_param) { - case 0: - if (dir == DIRECTION_INCREMENT && ch.clock_mod_index < MOD_CHOICE_SIZE - 1) { - ch.clock_mod_index += 1; - } else if (dir == DIRECTION_DECREMENT && ch.clock_mod_index > 0) { - ch.clock_mod_index -= 1; - } - break; - case 1: - ch.probability = constrain(ch.probability + val, 0, 100); - break; - case 2: - ch.duty_cycle = constrain(ch.duty_cycle + val, 0, 100); - break; - case 3: - ch.offset = constrain(ch.offset + val, 0, 100); - break; + case PARAM_MAIN_SOURCE: + if (static_cast(app.selected_source) == 0 && val < 0) { + app.selected_source = static_cast(SOURCE_LAST - 1); + } else { + app.selected_source = static_cast((app.selected_source + val) % SOURCE_LAST); + } + + gravity.clock.SetSource(app.selected_source); + app.refresh_screen = true; + break; + } } - uint32_t mod_pulses = clock_mod_pulses[ch.clock_mod_index]; - ch.duty_cycle_pulses = max((int)((mod_pulses * (100L - ch.duty_cycle)) / 100L), 1); - ch.offset_pulses = (int)(mod_pulses * (100L - ch.offset) / 100L); + // Selected Output Channel Settings. + else { + auto& ch = GetSelectedChannel(); - app.refresh_screen = true; + switch (static_cast(app.selected_param)) { + case PARAM_CH_MOD: + if (dir == DIRECTION_INCREMENT && ch.clock_mod_index < MOD_CHOICE_SIZE - 1) { + ch.clock_mod_index += 1; + } else if (dir == DIRECTION_DECREMENT && ch.clock_mod_index > 0) { + ch.clock_mod_index -= 1; + } + break; + case PARAM_CH_PROB: + ch.probability = constrain(ch.probability + val, 0, 100); + break; + case PARAM_CH_DUTY: + ch.duty_cycle = constrain(ch.duty_cycle + val, 0, 99); + break; + case PARAM_CH_OFFSET: + ch.offset = constrain(ch.offset + val, 0, 99); + break; + } + uint32_t mod_pulses = clock_mod_pulses[ch.clock_mod_index]; + ch.duty_cycle_pulses = max((int)((mod_pulses * (100L - ch.duty_cycle)) / 100L), 1); + ch.offset_pulses = (int)(mod_pulses * (100L - ch.offset) / 100L); + } } + app.refresh_screen = true; } void HandlePressedRotate(Direction dir, int val) { @@ -240,6 +268,18 @@ void ResetOutputs() { // 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 = 42; +constexpr int MENU_ITEM_HEIGHT = 14; +constexpr int MENU_BOX_PADDING = 4; +constexpr int MENU_BOX_WIDTH = 64; +constexpr int VISIBLE_MENU_ITEMS = 3; +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(); @@ -254,109 +294,50 @@ void UpdateDisplay() { } while (gravity.display.nextPage()); } -void DisplaySelectedChannel() { - int top = 50; - int boxWidth = 18; - int boxHeight = 14; - gravity.display.drawHLine(1, top, 126); - for (int i = 0; i < 7; i++) { - gravity.display.setDrawColor(1); - (app.selected_channel == i) - ? gravity.display.drawBox(i * boxWidth, top, boxWidth, boxHeight) - : gravity.display.drawVLine(i * boxWidth, top, boxHeight); - - gravity.display.setDrawColor(2); - if (i == 0) { - gravity.display.setDrawColor(2); - gravity.display.setBitmapMode(1); - auto icon = gravity.clock.IsPaused() ? pause_icon : play_icon; - gravity.display.drawXBM(2, top, play_icon_width, play_icon_height, icon); - } else { - gravity.display.setFont(TEXT_FONT); - gravity.display.setCursor((i * boxWidth) + 7, 63); - gravity.display.print(i); - } - } - gravity.display.drawVLine(126, top, boxHeight); -} - void DisplayMainPage() { gravity.display.setFontMode(1); gravity.display.setDrawColor(2); gravity.display.setFont(TEXT_FONT); - int textWidth; - int textY = 26; - int subTextY = 42; + // Display selected editable value + char mainText[8]; + const char* subText; - // Display selected editable value. if (app.selected_param == 0) { - gravity.display.setFont(LARGE_FONT); - char num_str[3]; // Serial MIID is too unstable to display bpm in real time. if (app.selected_source == SOURCE_EXTERNAL_MIDI) { - sprintf(num_str, "%s", "EXT"); + sprintf(mainText, "%s", "EXT"); } else { - sprintf(num_str, "%d", gravity.clock.Tempo()); + sprintf(mainText, "%d", gravity.clock.Tempo()); } - textWidth = gravity.display.getUTF8Width(num_str); - gravity.display.drawStr(32 - (textWidth / 2), textY, num_str); - gravity.display.setFont(TEXT_FONT); - textWidth = gravity.display.getUTF8Width("BPM"); - gravity.display.drawStr(32 - (textWidth / 2), subTextY, "BPM"); + subText = "BPM"; } else if (app.selected_param == 1) { switch (app.selected_source) { case SOURCE_INTERNAL: - gravity.display.setFont(LARGE_FONT); - textWidth = gravity.display.getUTF8Width("INT"); - gravity.display.drawStr(32 - (textWidth / 2), textY, "INT"); - gravity.display.setFont(TEXT_FONT); - textWidth = gravity.display.getUTF8Width("Clock"); - gravity.display.drawStr(32 - (textWidth / 2), subTextY, "Clock"); + sprintf(mainText, "%s", "INT"); + subText = "Clock"; break; case SOURCE_EXTERNAL_PPQN_24: - gravity.display.setFont(LARGE_FONT); - textWidth = gravity.display.getUTF8Width("EXT"); - gravity.display.drawStr(32 - (textWidth / 2), textY, "EXT"); - gravity.display.setFont(TEXT_FONT); - textWidth = gravity.display.getUTF8Width("24 PPQN"); - gravity.display.drawStr(32 - (textWidth / 2), subTextY, "24 PPQN"); + sprintf(mainText, "%s", "EXT"); + subText = "24 PPQN"; break; case SOURCE_EXTERNAL_PPQN_4: - gravity.display.setFont(LARGE_FONT); - textWidth = gravity.display.getUTF8Width("EXT"); - gravity.display.drawStr(32 - (textWidth / 2), textY, "EXT"); - gravity.display.setFont(TEXT_FONT); - textWidth = gravity.display.getUTF8Width("4 PPQN"); - gravity.display.drawStr(32 - (textWidth / 2), subTextY, "4 PPQN"); + sprintf(mainText, "%s", "EXT"); + subText = "4 PPQN"; break; case SOURCE_EXTERNAL_MIDI: - gravity.display.setFont(LARGE_FONT); - textWidth = gravity.display.getUTF8Width("EXT"); - gravity.display.drawStr(32 - (textWidth / 2), textY, "EXT"); - gravity.display.setFont(TEXT_FONT); - textWidth = gravity.display.getUTF8Width("MIDI"); - gravity.display.drawStr(32 - (textWidth / 2), subTextY, "MIDI"); + sprintf(mainText, "%s", "EXT"); + subText = "MIDI"; break; } } - int idx; - int drawX; - int height = 14; - int padding = 4; + drawCenteredText(mainText, MAIN_TEXT_Y, LARGE_FONT); + drawCenteredText(subText, SUB_TEXT_Y, TEXT_FONT); - // Draw selected menu item box. - gravity.display.drawBox(65, (height * app.selected_param) + 2, 63, height + 1); - - // Draw each menu item. - textWidth = gravity.display.getUTF8Width("Tempo"); - drawX = (SCREEN_WIDTH - textWidth) - padding; - gravity.display.drawStr(drawX, height * ++idx, "Tempo"); - - textWidth = gravity.display.getUTF8Width("Source"); - drawX = (SCREEN_WIDTH - textWidth) - padding; - gravity.display.drawStr(drawX, height * ++idx, "Source"); + // Draw Main Page menu items + const char* menu_items[PARAM_MAIN_LAST] = {"Tempo", "Source"}; + drawMenuItems(menu_items); } void DisplayChannelPage() { @@ -364,89 +345,123 @@ void DisplayChannelPage() { gravity.display.setFontMode(1); gravity.display.setDrawColor(2); - gravity.display.setFont(LARGE_FONT); - int textWidth; - int textY = 26; - int subTextY = 42; - char num_str[4]; + // Display selected editable value + char mainText[5]; + const char* subText; - // Display selected editable value. switch (app.selected_param) { - case 0: // Clock Mod - char mod_str[4]; - if (clock_mod[ch.clock_mod_index] > 1) { - sprintf(mod_str, "/%d", clock_mod[ch.clock_mod_index]); - textWidth = gravity.display.getUTF8Width(mod_str); - gravity.display.drawStr(32 - (textWidth / 2), textY, mod_str); - gravity.display.setFont(TEXT_FONT); - textWidth = gravity.display.getUTF8Width("Divide"); - gravity.display.drawStr(32 - (textWidth / 2), subTextY, "Divide"); - + case 0: { // Clock Mod + int mod_value = clock_mod[ch.clock_mod_index]; + if (mod_value > 1) { + sprintf(mainText, "/%d", mod_value); + subText = "Divide"; } else { - sprintf(mod_str, "x%d", abs(clock_mod[ch.clock_mod_index])); - textWidth = gravity.display.getUTF8Width(mod_str); - gravity.display.drawStr(32 - (textWidth / 2), textY, mod_str); - gravity.display.setFont(TEXT_FONT); - textWidth = gravity.display.getUTF8Width("Multiply"); - gravity.display.drawStr(32 - (textWidth / 2), subTextY, "Multiply"); + sprintf(mainText, "x%d", abs(mod_value)); + subText = "Multiply"; } break; + } case 1: // Probability - sprintf(num_str, "%d%%", ch.probability); - textWidth = gravity.display.getUTF8Width(num_str); - gravity.display.drawStr(32 - (textWidth / 2), textY, num_str); - gravity.display.setFont(TEXT_FONT); - textWidth = gravity.display.getUTF8Width("Hit Chance"); - gravity.display.drawStr(32 - (textWidth / 2), subTextY, "Hit Chance"); + sprintf(mainText, "%d%%", ch.probability); + subText = "Hit Chance"; break; case 2: // Duty Cycle - sprintf(num_str, "%d%%", ch.duty_cycle); - textWidth = gravity.display.getUTF8Width(num_str); - gravity.display.drawStr(32 - (textWidth / 2), textY, num_str); - gravity.display.setFont(TEXT_FONT); - textWidth = gravity.display.getUTF8Width("Pulse Width"); - gravity.display.drawStr(32 - (textWidth / 2), subTextY, "Pulse Width"); + sprintf(mainText, "%d%%", ch.duty_cycle); + subText = "Pulse Width"; break; case 3: // Offset - sprintf(num_str, "%d%%", ch.offset); - textWidth = gravity.display.getUTF8Width(num_str); - gravity.display.drawStr(32 - (textWidth / 2), textY, num_str); - gravity.display.setFont(TEXT_FONT); - textWidth = gravity.display.getUTF8Width("Shift Hit"); - gravity.display.drawStr(32 - (textWidth / 2), subTextY, "Shift Hit"); + sprintf(mainText, "%d%%", ch.offset); + subText = "Shift Hit"; break; } - int idx = 0; - int drawX; - int height = 14; - int padding = 4; + 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 Cycle", "Offset"}; + drawMenuItems(menu_items); +} + +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 < 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 - 1); + gravity.display.print(i); + } + } +} + +void drawMenuItems(const char* menu_items[]) { + // Draw menu items gravity.display.setFont(TEXT_FONT); - int textHeight = gravity.display.getFontAscent(); - // Draw selected menu item box. - gravity.display.drawBox(65, (height * min(2, app.selected_param)) + 2, 63, height + 1); - - // Draw each menu item. - if (app.selected_param < 3) { - textWidth = gravity.display.getUTF8Width("Mod"); - drawX = (SCREEN_WIDTH - textWidth) - padding; - gravity.display.drawStr(drawX, (height * ++idx), "Mod"); + // Draw selected menu item box + int selectedBoxY = 0; + if (app.selected_param == PARAM_CH_LAST - 1) { + selectedBoxY = MENU_ITEM_HEIGHT * min(2, app.selected_param); + } else if (app.selected_param > 0) { + selectedBoxY = MENU_ITEM_HEIGHT; } - textWidth = gravity.display.getUTF8Width("Probability"); - drawX = (SCREEN_WIDTH - textWidth) - padding; - gravity.display.drawStr(drawX, (height * ++idx), "Probability"); + int boxX = MENU_BOX_WIDTH + 1; + int boxY = selectedBoxY + 2; + int boxWidth = MENU_BOX_WIDTH - 1; + int boxHeight = MENU_ITEM_HEIGHT + 1; - textWidth = gravity.display.getUTF8Width("Duty Cycle"); - drawX = (SCREEN_WIDTH - textWidth) - padding; - gravity.display.drawStr(drawX, (height * ++idx), "Duty Cycle"); + app.editing_param + ? gravity.display.drawBox(boxX, boxY, boxWidth, boxHeight) + : gravity.display.drawFrame(boxX, boxY, boxWidth, boxHeight); - if (app.selected_param > 2) { - textWidth = gravity.display.getUTF8Width("Offset"); - drawX = (SCREEN_WIDTH - textWidth) - padding; - gravity.display.drawStr(drawX, (height * ++idx), "Offset"); + // Draw the visible menu items + int start_index = 0; + if (app.selected_param == PARAM_CH_LAST - 1) { + start_index = PARAM_CH_LAST - VISIBLE_MENU_ITEMS; + } else if (app.selected_param > 0) { + start_index = app.selected_param - 1; } + + for (int i = 0; i < VISIBLE_MENU_ITEMS; ++i) { + int idx = start_index + i; + drawRightAlignedText(menu_items[idx], MENU_ITEM_HEIGHT * (i + 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); } \ No newline at end of file -- 2.39.5 From 6c85b94f215054f461e93dab67d84ea608db54db Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Mon, 9 Jun 2025 19:54:30 -0700 Subject: [PATCH 15/69] Refactor handle rotate to make the code more reusable and readable. --- examples/clock_mod/clock_mod.ino | 157 ++++++++++++++++--------------- 1 file changed, 80 insertions(+), 77 deletions(-) diff --git a/examples/clock_mod/clock_mod.ino b/examples/clock_mod/clock_mod.ino index 3f3b65b..e9458ea 100644 --- a/examples/clock_mod/clock_mod.ino +++ b/examples/clock_mod/clock_mod.ino @@ -31,7 +31,7 @@ struct Channel { struct AppState { bool refresh_screen = true; bool editing_param = false; - byte selected_param = 0; + int selected_param = 0; byte selected_channel = 0; // 0=tempo, 1-6=output channel Source selected_source = SOURCE_INTERNAL; Channel channel[OUTPUT_COUNT]; @@ -43,6 +43,12 @@ enum ParamsMainPage { PARAM_MAIN_SOURCE, PARAM_MAIN_LAST, }; + +const char* MAIN_PAGE_MENU[PARAM_MAIN_LAST] = { + "Tempo", + "Source", +}; + enum ParamsChannelPage { PARAM_CH_MOD, PARAM_CH_PROB, @@ -51,6 +57,13 @@ enum ParamsChannelPage { PARAM_CH_LAST, }; +const char* CHANNEL_PAGE_MENU[PARAM_CH_LAST] = { + "Mod", + "Probability", + "Duty Cycle", + "Offset", +}; + // The number of clock mod options, hepls validate choices and pulses arrays are the same size. const int MOD_CHOICE_SIZE = 21; // Negative for multiply, positive for divide. @@ -166,77 +179,19 @@ void HandleEncoderPressed() { } void HandleRotate(Direction dir, int val) { - // Select a prameter when not in edit mode. if (!app.editing_param) { - // Main Global Settings Page. + // 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) { - if (app.selected_param == 0 && val < 0) { - app.selected_param = PARAM_MAIN_LAST - 1; - } else { - app.selected_param = (app.selected_param + val) % PARAM_MAIN_LAST; - } - } - // Selected Output Channels 1-6 Settings. - else { - if (app.selected_param == 0 && val < 0) { - app.selected_param = PARAM_CH_LAST - 1; - } else { - app.selected_param = (app.selected_param + val) % PARAM_CH_LAST; - } + editMainParameter(val); + } else { + editChannelParameter(dir, val); } } - // Edit selected param. - else { - // Main Global Settings Page. - if (app.selected_channel == 0) { - switch (static_cast(app.selected_param)) { - case PARAM_MAIN_TEMPO: - if (gravity.clock.ExternalSource()) { - break; - } - gravity.clock.SetTempo(gravity.clock.Tempo() + val); - app.refresh_screen = true; - break; - case PARAM_MAIN_SOURCE: - if (static_cast(app.selected_source) == 0 && val < 0) { - app.selected_source = static_cast(SOURCE_LAST - 1); - } else { - app.selected_source = static_cast((app.selected_source + val) % SOURCE_LAST); - } - - gravity.clock.SetSource(app.selected_source); - app.refresh_screen = true; - break; - } - } - // Selected Output Channel Settings. - else { - auto& ch = GetSelectedChannel(); - - switch (static_cast(app.selected_param)) { - case PARAM_CH_MOD: - if (dir == DIRECTION_INCREMENT && ch.clock_mod_index < MOD_CHOICE_SIZE - 1) { - ch.clock_mod_index += 1; - } else if (dir == DIRECTION_DECREMENT && ch.clock_mod_index > 0) { - ch.clock_mod_index -= 1; - } - break; - case PARAM_CH_PROB: - ch.probability = constrain(ch.probability + val, 0, 100); - break; - case PARAM_CH_DUTY: - ch.duty_cycle = constrain(ch.duty_cycle + val, 0, 99); - break; - case PARAM_CH_OFFSET: - ch.offset = constrain(ch.offset + val, 0, 99); - break; - } - uint32_t mod_pulses = clock_mod_pulses[ch.clock_mod_index]; - ch.duty_cycle_pulses = max((int)((mod_pulses * (100L - ch.duty_cycle)) / 100L), 1); - ch.offset_pulses = (int)(mod_pulses * (100L - ch.offset) / 100L); - } - } app.refresh_screen = true; } @@ -250,6 +205,57 @@ void HandlePressedRotate(Direction dir, int val) { 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); + break; + + case PARAM_MAIN_SOURCE: { + int source = static_cast(app.selected_source); + updateSelection(source, val, SOURCE_LAST); + app.selected_source = static_cast(source); + gravity.clock.SetSource(app.selected_source); + break; + } + } +} + +void editChannelParameter(Direction dir, int val) { + auto& ch = GetSelectedChannel(); + switch (static_cast(app.selected_param)) { + case PARAM_CH_MOD: + if (dir == DIRECTION_INCREMENT && ch.clock_mod_index < MOD_CHOICE_SIZE - 1) { + ch.clock_mod_index++; + } else if (dir == DIRECTION_DECREMENT && ch.clock_mod_index > 0) { + ch.clock_mod_index--; + } + break; + case PARAM_CH_PROB: + ch.probability = constrain(ch.probability + val, 0, 100); + break; + case PARAM_CH_DUTY: + ch.duty_cycle = constrain(ch.duty_cycle + val, 0, 99); + break; + case PARAM_CH_OFFSET: + ch.offset = constrain(ch.offset + val, 0, 99); + break; + } + + // Update this channel's parameters based on new values. + uint32_t mod_pulses = clock_mod_pulses[ch.clock_mod_index]; + ch.duty_cycle_pulses = max((int)((mod_pulses * (100L - ch.duty_cycle)) / 100L), 1); + ch.offset_pulses = (int)(mod_pulses * (100L - ch.offset) / 100L); +} + +void updateSelection(int& param, int change, int maxValue) { + // This formula correctly handles positive and negative wrapping. + param = (param + change % maxValue + maxValue) % maxValue; +} + // // Helper functions. // @@ -336,8 +342,7 @@ void DisplayMainPage() { drawCenteredText(subText, SUB_TEXT_Y, TEXT_FONT); // Draw Main Page menu items - const char* menu_items[PARAM_MAIN_LAST] = {"Tempo", "Source"}; - drawMenuItems(menu_items); + drawMenuItems(MAIN_PAGE_MENU, PARAM_MAIN_LAST); } void DisplayChannelPage() { @@ -380,9 +385,7 @@ void DisplayChannelPage() { drawCenteredText(subText, SUB_TEXT_Y, TEXT_FONT); // Draw Channel Page menu items - const char* menu_items[PARAM_CH_LAST] = { - "Mod", "Probability", "Duty Cycle", "Offset"}; - drawMenuItems(menu_items); + drawMenuItems(CHANNEL_PAGE_MENU, PARAM_CH_LAST); } void DisplaySelectedChannel() { @@ -417,13 +420,13 @@ void DisplaySelectedChannel() { } } -void drawMenuItems(const char* menu_items[]) { +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 (app.selected_param == PARAM_CH_LAST - 1) { + 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; @@ -440,13 +443,13 @@ void drawMenuItems(const char* menu_items[]) { // Draw the visible menu items int start_index = 0; - if (app.selected_param == PARAM_CH_LAST - 1) { - start_index = PARAM_CH_LAST - VISIBLE_MENU_ITEMS; + 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 < VISIBLE_MENU_ITEMS; ++i) { + 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)); } -- 2.39.5 From 8aa47b73fda02cd6538467c532e46d7f15ba3856 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Mon, 9 Jun 2025 22:33:16 -0700 Subject: [PATCH 16/69] Add new official Gravity firmware code. Split out the output channel behavior and struct into a new class. --- examples/Gravity/Gravity.ino | 419 +++++++++++++++++++++++++++++++++++ examples/Gravity/channel.h | 99 +++++++++ 2 files changed, 518 insertions(+) create mode 100644 examples/Gravity/Gravity.ino create mode 100644 examples/Gravity/channel.h diff --git a/examples/Gravity/Gravity.ino b/examples/Gravity/Gravity.ino new file mode 100644 index 0000000..04f248e --- /dev/null +++ b/examples/Gravity/Gravity.ino @@ -0,0 +1,419 @@ +/** + * @file clock_mod.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 "channel.h" + +// Firmware state variables. +struct AppState { + bool refresh_screen = true; + bool editing_param = false; + int selected_param = 0; + byte selected_channel = 0; // 0=tempo, 1-6=output channel + Source selected_source = SOURCE_INTERNAL; + Channel channel[OUTPUT_COUNT]; +}; +AppState app; + +enum ParamsMainPage { + PARAM_MAIN_TEMPO, + PARAM_MAIN_SOURCE, + PARAM_MAIN_LAST, +}; + +enum ParamsChannelPage { + PARAM_CH_MOD, + PARAM_CH_PROB, + PARAM_CH_DUTY, + PARAM_CH_OFFSET, + PARAM_CH_LAST, +}; + +const auto TEXT_FONT = u8g2_font_missingplanet_tr; +const auto LARGE_FONT = u8g2_font_maniac_tr; + +#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(); + + // 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(); + + if (app.refresh_screen) { + UpdateDisplay(); + } +} + +// +// Firmware handlers for clocks. +// + +void HandleIntClockTick(uint32_t tick) { + for (int i = 0; i < OUTPUT_COUNT; i++) { + app.channel[i].processClockTick(tick, gravity.outputs[i]); + } +} + +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() { + 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(dir, val); + } + } + app.refresh_screen = true; +} + +void HandlePressedRotate(Direction dir, int val) { + if (dir == DIRECTION_INCREMENT && app.selected_channel < OUTPUT_COUNT) { + app.selected_channel++; + } else if (dir == DIRECTION_DECREMENT && app.selected_channel > 0) { + app.selected_channel--; + } + app.selected_param = 0; + 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); + break; + + case PARAM_MAIN_SOURCE: { + int source = static_cast(app.selected_source); + updateSelection(source, val, SOURCE_LAST); + app.selected_source = static_cast(source); + gravity.clock.SetSource(app.selected_source); + break; + } + } +} + +void editChannelParameter(Direction dir, int val) { + auto& ch = GetSelectedChannel(); + switch (static_cast(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; + } +} + +void updateSelection(int& param, int change, int maxValue) { + // This formula correctly handles positive and negative wrapping. + param = (param + change % maxValue + maxValue) % maxValue; +} + +// +// Helper functions. +// + +Channel& GetSelectedChannel() { + return app.channel[app.selected_channel - 1]; +} + +void ResetOutputs() { + for (int i = 0; i < 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 = 42; +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 == 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 SOURCE_INTERNAL: + sprintf(mainText, "%s", "INT"); + subText = "Clock"; + break; + case SOURCE_EXTERNAL_PPQN_24: + sprintf(mainText, "%s", "EXT"); + subText = "24 PPQN"; + break; + case SOURCE_EXTERNAL_PPQN_4: + sprintf(mainText, "%s", "EXT"); + subText = "4 PPQN"; + break; + case SOURCE_EXTERNAL_MIDI: + sprintf(mainText, "%s", "EXT"); + subText = "MIDI"; + 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"}; + 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; + + switch (app.selected_param) { + case 0: { // Clock Mod + int mod_value = ch.getClockMod(); + if (mod_value > 1) { + sprintf(mainText, "/%d", mod_value); + subText = "Divide"; + } else { + sprintf(mainText, "x%d", abs(mod_value)); + subText = "Multiply"; + } + break; + } + case 1: // Probability + sprintf(mainText, "%d%%", ch.getProbability()); + subText = "Hit Chance"; + break; + case 2: // Duty Cycle + sprintf(mainText, "%d%%", ch.getDutyCycle()); + subText = "Pulse Width"; + break; + case 3: // Offset + sprintf(mainText, "%d%%", ch.getOffset()); + subText = "Shift Hit"; + 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 Cycle", "Offset"}; + 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 < 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 - 1); + 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)); + } +} + +// 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); +} \ No newline at end of file diff --git a/examples/Gravity/channel.h b/examples/Gravity/channel.h new file mode 100644 index 0000000..99611d7 --- /dev/null +++ b/examples/Gravity/channel.h @@ -0,0 +1,99 @@ +#ifndef CHANNEL_H +#define CHANNEL_H + +#include +#include + + +static const int MOD_CHOICE_SIZE = 21; +// Negative for multiply, positive for divide. +static const int clock_mod[MOD_CHOICE_SIZE] = {-24, -12, -8, -6, -4, -3, -2, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 24, 32, 64, 128}; +// 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] = {4, 8, 12, 16, 24, 32, 48, 96, 192, 288, 384, 480, 576, 1152, 672, 768, 1536, 2304, 3072, 6144, 12288}; + + +class Channel { +public: + /** + * @brief Construct a new Channel object with default values. + */ + Channel() { + updatePulses(); + } + + // --- Setters for channel properties --- + + void setClockMod(int index) { + if (index >= 0 && index < MOD_CHOICE_SIZE) { + clock_mod_index = index; + updatePulses(); + } + } + + void setProbability(int prob) { + probability = constrain(prob, 0, 100); + } + + void setDutyCycle(int duty) { + duty_cycle = constrain(duty, 0, 99); + updatePulses(); + } + + void setOffset(int off) { + offset = constrain(off, 0, 99); + updatePulses(); + } + + // --- Getters for channel properties --- + + int getClockModIndex() const { return clock_mod_index; } + int getProbability() const { return probability; } + int getDutyCycle() const { return duty_cycle; } + int getOffset() const { return offset; } + int getClockMod() const { return clock_mod[clock_mod_index]; } + uint32_t getDutyCyclePulses() const { return duty_cycle_pulses; } + uint32_t getOffsetPulses() const { return offset_pulses; } + + /** + * @brief Processes a clock tick and determines if the output should be high or low. + * @param tick The current clock tick count. + * @param output The output object (or a reference to its state) to be modified. + */ + void processClockTick(uint32_t tick, DigitalOutput& output) { + const uint32_t mod_pulses = clock_mod_pulses[clock_mod_index]; + const uint32_t current_tick_offset = tick + offset_pulses; + + // Duty cycle high check + if (current_tick_offset % mod_pulses == 0) { + if (probability >= random(0, 100)) { + output.High(); + } + } + + // Duty cycle low check + const uint32_t duty_cycle_end_tick = tick + duty_cycle_pulses + offset_pulses; + if (duty_cycle_end_tick % mod_pulses == 0) { + output.Low(); + } + } + +private: + /** + * @brief Recalculates pulse values based on current channel settings. + * Should be called whenever mod, duty cycle, or offset changes. + */ + void updatePulses() { + uint32_t mod_pulses = clock_mod_pulses[clock_mod_index]; + duty_cycle_pulses = max((long)((mod_pulses * (100L - duty_cycle)) / 100L), 1L); + offset_pulses = (long)((mod_pulses * (100L - offset)) / 100L); + } + + byte clock_mod_index = 7; // 1x + byte probability = 100; + byte duty_cycle = 50; + byte offset = 0; + int duty_cycle_pulses; + int offset_pulses; +}; + +#endif // CHANNEL_H \ No newline at end of file -- 2.39.5 From 70b9b280322763b5772fd1811e3f752540d36f09 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Mon, 9 Jun 2025 22:36:58 -0700 Subject: [PATCH 17/69] minor formatting --- examples/Gravity/channel.h | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/examples/Gravity/channel.h b/examples/Gravity/channel.h index 99611d7..39ee574 100644 --- a/examples/Gravity/channel.h +++ b/examples/Gravity/channel.h @@ -4,16 +4,14 @@ #include #include - static const int MOD_CHOICE_SIZE = 21; // Negative for multiply, positive for divide. static const int clock_mod[MOD_CHOICE_SIZE] = {-24, -12, -8, -6, -4, -3, -2, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 24, 32, 64, 128}; // 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] = {4, 8, 12, 16, 24, 32, 48, 96, 192, 288, 384, 480, 576, 1152, 672, 768, 1536, 2304, 3072, 6144, 12288}; - class Channel { -public: + public: /** * @brief Construct a new Channel object with default values. */ @@ -21,7 +19,7 @@ public: updatePulses(); } - // --- Setters for channel properties --- + // Setters for channel properties void setClockMod(int index) { if (index >= 0 && index < MOD_CHOICE_SIZE) { @@ -44,9 +42,8 @@ public: updatePulses(); } - // --- Getters for channel properties --- + // Getters for channel properties - int getClockModIndex() const { return clock_mod_index; } int getProbability() const { return probability; } int getDutyCycle() const { return duty_cycle; } int getOffset() const { return offset; } @@ -77,7 +74,7 @@ public: } } -private: + private: /** * @brief Recalculates pulse values based on current channel settings. * Should be called whenever mod, duty cycle, or offset changes. @@ -88,7 +85,7 @@ private: offset_pulses = (long)((mod_pulses * (100L - offset)) / 100L); } - byte clock_mod_index = 7; // 1x + byte clock_mod_index = 7; // 1x clock mod byte probability = 100; byte duty_cycle = 50; byte offset = 0; @@ -96,4 +93,4 @@ private: int offset_pulses; }; -#endif // CHANNEL_H \ No newline at end of file +#endif // CHANNEL_H \ No newline at end of file -- 2.39.5 From 9bacf43f155998c5d4baae7eb0248196b496b697 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 14 Jun 2025 21:26:33 +0000 Subject: [PATCH 18/69] Add per-channel CV Input mod configuration (#4) Each channel can enable CV 1 or CV 2 as an input source for modulation, which can be applied to any of the user-editable parameters. When editing the parameter, cv mod is not applied in the UI so the user can easily see the base value for editing. When not editing, the UI will display the current cv modded value in the UI for the modded parameter. I had originally intended to provide configuration for attenuating and offsetting the cv input per channel, but that introduced a significant amount of memory needed to store several new ints per channel. I may return to add this feature later, but given it's something that can easily be done with other modules between the modulation source and Gravity cv input, I am deprioritizing this feature. Reviewed-on: https://git.pinkduck.xyz/adam/libGravity/pulls/4 Co-authored-by: Adam Wonak Co-committed-by: Adam Wonak --- .gitignore | 3 +- examples/Gravity/Gravity.ino | 101 ++++++++++++++++++++++--- examples/Gravity/channel.h | 138 +++++++++++++++++++++++------------ peripherials.h | 2 +- 4 files changed, 185 insertions(+), 59 deletions(-) diff --git a/.gitignore b/.gitignore index e156017..41cc7c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ docs -.vscode \ No newline at end of file +.vscode +.DS_Store \ No newline at end of file diff --git a/examples/Gravity/Gravity.ino b/examples/Gravity/Gravity.ino index 04f248e..44596c8 100644 --- a/examples/Gravity/Gravity.ino +++ b/examples/Gravity/Gravity.ino @@ -43,6 +43,8 @@ enum ParamsChannelPage { PARAM_CH_PROB, PARAM_CH_DUTY, PARAM_CH_OFFSET, + PARAM_CH_CV_SRC, + PARAM_CH_CV_DEST, PARAM_CH_LAST, }; @@ -86,6 +88,13 @@ 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 < OUTPUT_COUNT; i++) { + app.channel[i].applyCvMod(cv1, cv2); + } + if (app.refresh_screen) { UpdateDisplay(); } @@ -96,8 +105,17 @@ void loop() { // void HandleIntClockTick(uint32_t tick) { + bool refresh = false; for (int i = 0; i < 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; } } @@ -143,7 +161,7 @@ void HandleRotate(Direction dir, int val) { if (app.selected_channel == 0) { editMainParameter(val); } else { - editChannelParameter(dir, val); + editChannelParameter(val); } } app.refresh_screen = true; @@ -178,9 +196,9 @@ void editMainParameter(int val) { } } -void editChannelParameter(Direction dir, int val) { +void editChannelParameter(int val) { auto& ch = GetSelectedChannel(); - switch (static_cast(app.selected_param)) { + switch (app.selected_param) { case PARAM_CH_MOD: ch.setClockMod(ch.getClockModIndex() + val); break; @@ -193,6 +211,18 @@ void editChannelParameter(Direction dir, int val) { 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; + } } } @@ -303,9 +333,13 @@ void DisplayChannelPage() { 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 0: { // Clock Mod - int mod_value = ch.getClockMod(); + case PARAM_CH_MOD: { + int mod_value = ch.getClockMod(withCvMod); if (mod_value > 1) { sprintf(mainText, "/%d", mod_value); subText = "Divide"; @@ -315,25 +349,68 @@ void DisplayChannelPage() { } break; } - case 1: // Probability - sprintf(mainText, "%d%%", ch.getProbability()); + case PARAM_CH_PROB: + sprintf(mainText, "%d%%", ch.getProbability(withCvMod)); subText = "Hit Chance"; break; - case 2: // Duty Cycle - sprintf(mainText, "%d%%", ch.getDutyCycle()); + case PARAM_CH_DUTY: + sprintf(mainText, "%d%%", ch.getDutyCycle(withCvMod)); subText = "Pulse Width"; break; - case 3: // Offset - sprintf(mainText, "%d%%", ch.getOffset()); + 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 Cycle", "Offset"}; + const char* menu_items[PARAM_CH_LAST] = { + "Mod", "Probability", "Duty", "Offset", "CV Source", "CV Dest"}; drawMenuItems(menu_items, PARAM_CH_LAST); } diff --git a/examples/Gravity/channel.h b/examples/Gravity/channel.h index 39ee574..a558adf 100644 --- a/examples/Gravity/channel.h +++ b/examples/Gravity/channel.h @@ -4,6 +4,23 @@ #include #include +// Enums for CV configuration +enum CvSource { + CV_NONE, + CV_1, + CV_2, + CV_LAST, +}; + +enum CvDestination { + CV_DEST_NONE, + CV_DEST_MOD, + CV_DEST_PROB, + CV_DEST_DUTY, + CV_DEST_OFFSET, + CV_DEST_LAST, +}; + static const int MOD_CHOICE_SIZE = 21; // Negative for multiply, positive for divide. static const int clock_mod[MOD_CHOICE_SIZE] = {-24, -12, -8, -6, -4, -3, -2, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 24, 32, 64, 128}; @@ -12,85 +29,116 @@ static const int clock_mod_pulses[MOD_CHOICE_SIZE] = {4, 8, 12, 16, 24, 32, 48, class Channel { public: - /** - * @brief Construct a new Channel object with default values. - */ Channel() { - updatePulses(); + cvmod_clock_mod_index = base_clock_mod_index; + cvmod_probability = base_probability; + cvmod_duty_cycle = base_duty_cycle; + cvmod_offset = base_offset; } - // Setters for channel properties + // Setters (Set the BASE value) void setClockMod(int index) { - if (index >= 0 && index < MOD_CHOICE_SIZE) { - clock_mod_index = index; - updatePulses(); - } + if (index >= 0 && index < MOD_CHOICE_SIZE) base_clock_mod_index = index; } + void setProbability(int prob) { base_probability = constrain(prob, 0, 100); } + void setDutyCycle(int duty) { base_duty_cycle = constrain(duty, 1, 99); } + void setOffset(int off) { base_offset = constrain(off, 0, 100); } + void setCvSource(CvSource source) { cv_source = source; } + void setCvDestination(CvDestination dest) { cv_destination = dest; } - void setProbability(int prob) { - probability = constrain(prob, 0, 100); - } + // Getters (Get the BASE value for editing or cv modded value for display) - void setDutyCycle(int duty) { - duty_cycle = constrain(duty, 0, 99); - updatePulses(); - } - - void setOffset(int off) { - offset = constrain(off, 0, 99); - updatePulses(); - } - - // Getters for channel properties - - int getProbability() const { return probability; } - int getDutyCycle() const { return duty_cycle; } - int getOffset() const { return offset; } - int getClockMod() const { return clock_mod[clock_mod_index]; } + 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 getClockMod(bool withCvMod = false) const { return clock_mod[getClockModIndex(withCvMod)]; } + int getClockModIndex(bool withCvMod = false) const { return withCvMod ? cvmod_clock_mod_index : base_clock_mod_index; } uint32_t getDutyCyclePulses() const { return duty_cycle_pulses; } uint32_t getOffsetPulses() const { return offset_pulses; } + CvSource getCvSource() { return cv_source; } + CvDestination getCvDestination() { return cv_destination; } + bool isCvModActive() const { return cv_source != CV_NONE && cv_destination != CV_DEST_NONE; } /** * @brief Processes a clock tick and determines if the output should be high or low. * @param tick The current clock tick count. - * @param output The output object (or a reference to its state) to be modified. + * @param output The output object to be modified. */ void processClockTick(uint32_t tick, DigitalOutput& output) { - const uint32_t mod_pulses = clock_mod_pulses[clock_mod_index]; + // Calculate output duty cycle state using cv modded values to determine pulse counts. + const uint32_t mod_pulses = clock_mod_pulses[cvmod_clock_mod_index]; + const uint32_t duty_pulses = max((long)((mod_pulses * (100L - cvmod_duty_cycle)) / 100L), 1L); + const uint32_t offset_pulses = (long)((mod_pulses * (100L - cvmod_offset)) / 100L); + const uint32_t current_tick_offset = tick + offset_pulses; // Duty cycle high check if (current_tick_offset % mod_pulses == 0) { - if (probability >= random(0, 100)) { + if (cvmod_probability >= random(0, 100)) { output.High(); } } // Duty cycle low check - const uint32_t duty_cycle_end_tick = tick + duty_cycle_pulses + offset_pulses; + const uint32_t duty_cycle_end_tick = tick + duty_pulses + offset_pulses; if (duty_cycle_end_tick % mod_pulses == 0) { output.Low(); } } - private: - /** - * @brief Recalculates pulse values based on current channel settings. - * Should be called whenever mod, duty cycle, or offset changes. - */ - void updatePulses() { - uint32_t mod_pulses = clock_mod_pulses[clock_mod_index]; - duty_cycle_pulses = max((long)((mod_pulses * (100L - duty_cycle)) / 100L), 1L); - offset_pulses = (long)((mod_pulses * (100L - offset)) / 100L); + void applyCvMod(int cv1_value, int cv2_value) { + if (!isCvModActive()) { + // If CV is off, ensure cv modded values match the base values. + cvmod_clock_mod_index = base_clock_mod_index; + cvmod_probability = base_probability; + cvmod_duty_cycle = base_duty_cycle; + cvmod_offset = base_offset; + return; + } + + // Use the CV value for current selected cv source. + int value = (cv_source == CV_1) ? cv1_value : cv2_value; + + // Calculate and store cv modded values using bipolar mapping. + // Default to base value if not the current CV destination. + + cvmod_clock_mod_index = (cv_destination == CV_DEST_MOD) + ? constrain(base_clock_mod_index + map(value, -512, 512, -10, 10), 0, MOD_CHOICE_SIZE - 1) + : base_clock_mod_index; + + cvmod_probability = (cv_destination == CV_DEST_PROB) + ? constrain(base_probability + map(value, -512, 512, -50, 50), 0, 100) + : base_probability; + + cvmod_duty_cycle = (cv_destination == CV_DEST_DUTY) + ? constrain(base_duty_cycle + map(value, -512, 512, -50, 50), 1, 99) + : base_duty_cycle; + + cvmod_offset = (cv_destination == CV_DEST_OFFSET) + ? constrain(base_offset + map(value, -512, 512, -50, 50), 0, 99) + : base_offset; } - byte clock_mod_index = 7; // 1x clock mod - byte probability = 100; - byte duty_cycle = 50; - byte offset = 0; + private: + // User-settable "base" values. + byte base_clock_mod_index = 7; + byte base_probability = 100; + byte base_duty_cycle = 50; + byte base_offset = 0; + + // Base value with cv mod applied. + byte cvmod_clock_mod_index; + byte cvmod_probability; + byte cvmod_duty_cycle; + byte cvmod_offset; + int duty_cycle_pulses; int offset_pulses; + + // CV configuration + CvSource cv_source = CV_NONE; + CvDestination cv_destination = CV_DEST_NONE; }; #endif // CHANNEL_H \ No newline at end of file diff --git a/peripherials.h b/peripherials.h index 1864462..8f5b68d 100644 --- a/peripherials.h +++ b/peripherials.h @@ -19,7 +19,7 @@ // Peripheral input pins #define ENCODER_PIN1 17 // A3 #define ENCODER_PIN2 4 -#define ENCODER_SW_PIN 14 +#define ENCODER_SW_PIN 14 // A0 // Clock and CV Inputs #define EXT_PIN 2 -- 2.39.5 From 07ed4b3d9ac164643cc97184288d4fedbf3cfb36 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 14 Jun 2025 21:52:18 +0000 Subject: [PATCH 19/69] Update Font (#5) Reduce the amount of program memory used by switching to original firmware's optimized fonts. Reviewed-on: https://git.pinkduck.xyz/adam/libGravity/pulls/5 Co-authored-by: Adam Wonak Co-committed-by: Adam Wonak --- examples/Gravity/Gravity.ino | 82 +++++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 19 deletions(-) diff --git a/examples/Gravity/Gravity.ino b/examples/Gravity/Gravity.ino index 44596c8..f03adea 100644 --- a/examples/Gravity/Gravity.ino +++ b/examples/Gravity/Gravity.ino @@ -48,8 +48,52 @@ enum ParamsChannelPage { PARAM_CH_LAST, }; -const auto TEXT_FONT = u8g2_font_missingplanet_tr; -const auto LARGE_FONT = u8g2_font_maniac_tr; +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 @@ -252,7 +296,7 @@ void ResetOutputs() { // Constants for screen layout and fonts constexpr int SCREEN_CENTER_X = 32; constexpr int MAIN_TEXT_Y = 26; -constexpr int SUB_TEXT_Y = 42; +constexpr int SUB_TEXT_Y = 40; constexpr int VISIBLE_MENU_ITEMS = 3; constexpr int MENU_ITEM_HEIGHT = 14; constexpr int MENU_BOX_PADDING = 4; @@ -298,7 +342,7 @@ void DisplayMainPage() { switch (app.selected_source) { case SOURCE_INTERNAL: sprintf(mainText, "%s", "INT"); - subText = "Clock"; + subText = "CLOCK"; break; case SOURCE_EXTERNAL_PPQN_24: sprintf(mainText, "%s", "EXT"); @@ -319,7 +363,7 @@ void DisplayMainPage() { drawCenteredText(subText, SUB_TEXT_Y, TEXT_FONT); // Draw Main Page menu items - const char* menu_items[PARAM_MAIN_LAST] = {"Tempo", "Source"}; + const char* menu_items[PARAM_MAIN_LAST] = {"TEMPO", "SOURCE"}; drawMenuItems(menu_items, PARAM_MAIN_LAST); } @@ -342,30 +386,30 @@ void DisplayChannelPage() { int mod_value = ch.getClockMod(withCvMod); if (mod_value > 1) { sprintf(mainText, "/%d", mod_value); - subText = "Divide"; + subText = "DIVIDE"; } else { sprintf(mainText, "x%d", abs(mod_value)); - subText = "Multiply"; + subText = "MULTIPLY"; } break; } case PARAM_CH_PROB: sprintf(mainText, "%d%%", ch.getProbability(withCvMod)); - subText = "Hit Chance"; + subText = "HIT CHANCE"; break; case PARAM_CH_DUTY: sprintf(mainText, "%d%%", ch.getDutyCycle(withCvMod)); - subText = "Pulse Width"; + subText = "PULSE WIDTH"; break; case PARAM_CH_OFFSET: sprintf(mainText, "%d%%", ch.getOffset(withCvMod)); - subText = "Shift Hit"; + subText = "SHIFT HIT"; break; case PARAM_CH_CV_SRC: { switch (ch.getCvSource()) { case CV_NONE: sprintf(mainText, "SRC"); - subText = "None"; + subText = "NONE"; break; case CV_1: sprintf(mainText, "SRC"); @@ -382,23 +426,23 @@ void DisplayChannelPage() { switch (ch.getCvDestination()) { case CV_DEST_NONE: sprintf(mainText, "DEST"); - subText = "None"; + subText = "NONE"; break; case CV_DEST_MOD: sprintf(mainText, "DEST"); - subText = "Clock Mod"; + subText = "CLOCK MOD"; break; case CV_DEST_PROB: sprintf(mainText, "DEST"); - subText = "Probability"; + subText = "PROBABILITY"; break; case CV_DEST_DUTY: sprintf(mainText, "DEST"); - subText = "Duty Cycle"; + subText = "DUTY CYCLE"; break; case CV_DEST_OFFSET: sprintf(mainText, "DEST"); - subText = "Offset"; + subText = "OFFSET"; break; } break; @@ -410,7 +454,7 @@ void DisplayChannelPage() { // Draw Channel Page menu items const char* menu_items[PARAM_CH_LAST] = { - "Mod", "Probability", "Duty", "Offset", "CV Source", "CV Dest"}; + "MOD", "PROBABILITY", "DUTY", "OFFSET", "CV SOURCE", "CV DEST"}; drawMenuItems(menu_items, PARAM_CH_LAST); } @@ -440,7 +484,7 @@ void DisplaySelectedChannel() { 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 - 1); + gravity.display.setCursor((i * boxWidth) + textOffset, SCREEN_HEIGHT - 3); gravity.display.print(i); } } @@ -477,7 +521,7 @@ void drawMenuItems(const char* menu_items[], int menu_size) { 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)); + drawRightAlignedText(menu_items[idx], MENU_ITEM_HEIGHT * (i + 1) - 1); } } -- 2.39.5 From d12764313bee2fb3fe3cb96a4066a82f6a68c71a Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Mon, 16 Jun 2025 02:47:25 +0000 Subject: [PATCH 20/69] Introduce StateManager to persist state between power cycles (#6) - add reset state menu option to return all settings back to default values. - add reverse encoder menu option and save state - make saving to EEPROM safer by wrapping put calls with noInterrupts() - improve save state behavior by using a mutex flag and update check with debounce in main loop - refactor gravity.h global const definitions to be static and more readable. - improve usage of EncoderDir in ISR with pointer to instance and static isr() method. - reduce u8g2 memory usage by using single page buffer Reviewed-on: https://git.pinkduck.xyz/adam/libGravity/pulls/6 Co-authored-by: Adam Wonak Co-committed-by: Adam Wonak --- clock.h | 20 ++-- encoder_dir.h | 23 ++-- examples/Gravity/Gravity.ino | 182 ++++++++++++++++++++------------ examples/Gravity/app_state.h | 21 ++++ examples/Gravity/channel.h | 28 +++-- examples/Gravity/save_state.cpp | 119 +++++++++++++++++++++ examples/Gravity/save_state.h | 65 ++++++++++++ gravity.cpp | 19 ++-- gravity.h | 4 +- peripherials.h | 2 - 10 files changed, 378 insertions(+), 105 deletions(-) create mode 100644 examples/Gravity/app_state.h create mode 100644 examples/Gravity/save_state.cpp create mode 100644 examples/Gravity/save_state.h diff --git a/clock.h b/clock.h index 2a0e54b..19ed250 100644 --- a/clock.h +++ b/clock.h @@ -23,22 +23,22 @@ #define MIDI_STOP 0xFC #define MIDI_CONTINUE 0xFB -const int DEFAULT_TEMPO = 120; - typedef void (*ExtCallback)(void); static ExtCallback extUserCallback = nullptr; static void serialEventNoop(uint8_t msg, uint8_t status) {} -enum Source { - SOURCE_INTERNAL, - SOURCE_EXTERNAL_PPQN_24, - SOURCE_EXTERNAL_PPQN_4, - SOURCE_EXTERNAL_MIDI, - SOURCE_LAST, -}; - class Clock { public: + static constexpr int DEFAULT_TEMPO = 120; + + enum Source { + SOURCE_INTERNAL, + SOURCE_EXTERNAL_PPQN_24, + SOURCE_EXTERNAL_PPQN_4, + SOURCE_EXTERNAL_MIDI, + SOURCE_LAST, + }; + void Init() { NeoSerial.begin(31250); diff --git a/encoder_dir.h b/encoder_dir.h index dbc3c56..5375b57 100644 --- a/encoder_dir.h +++ b/encoder_dir.h @@ -34,7 +34,9 @@ class EncoderDir { public: EncoderDir() : encoder_(ENCODER_PIN1, ENCODER_PIN2, RotaryEncoder::LatchMode::FOUR3), - button_(ENCODER_SW_PIN) {} + button_(ENCODER_SW_PIN) { + _instance = this; + } ~EncoderDir() {} // Set to true if the encoder read direction should be reversed. @@ -81,15 +83,19 @@ class EncoderDir { } } - // Read the encoder state and update the read position. - void UpdateEncoder() { - encoder_.tick(); + static void isr() { + // If the instance has been created, call its tick() method. + if (_instance) { + _instance->encoder_.tick(); + } } private: + static EncoderDir* _instance; + int previous_pos_; bool rotated_while_held_; - bool reversed_ = true; + bool reversed_ = false; RotaryEncoder encoder_; Button button_; @@ -115,15 +121,18 @@ class EncoderDir { change *= 2; } + if (reversed_) { + change = -(change); + } return change; } inline Direction rotate_(int dir, bool reversed) { switch (dir) { case 1: - return (reversed) ? DIRECTION_INCREMENT : DIRECTION_DECREMENT; - case -1: return (reversed) ? DIRECTION_DECREMENT : DIRECTION_INCREMENT; + case -1: + return (reversed) ? DIRECTION_INCREMENT : DIRECTION_DECREMENT; default: return DIRECTION_UNCHANGED; } diff --git a/examples/Gravity/Gravity.ino b/examples/Gravity/Gravity.ino index f03adea..f3502ea 100644 --- a/examples/Gravity/Gravity.ino +++ b/examples/Gravity/Gravity.ino @@ -1,5 +1,5 @@ /** - * @file clock_mod.ino + * @file Gravity.ino * @author Adam Wonak (https://github.com/awonak/) * @brief Demo firmware for Sitka Instruments Gravity. * @version 0.1 @@ -19,22 +19,19 @@ #include +#include "app_state.h" #include "channel.h" +#include "save_state.h" -// Firmware state variables. -struct AppState { - bool refresh_screen = true; - bool editing_param = false; - int selected_param = 0; - byte selected_channel = 0; // 0=tempo, 1-6=output channel - Source selected_source = SOURCE_INTERNAL; - Channel channel[OUTPUT_COUNT]; -}; AppState app; +StateManager stateManager; + enum ParamsMainPage { PARAM_MAIN_TEMPO, PARAM_MAIN_SOURCE, + PARAM_MAIN_ENCODER_DIR, + PARAM_MAIN_RESET_STATE, PARAM_MAIN_LAST, }; @@ -48,52 +45,52 @@ enum ParamsChannelPage { 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 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"; +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 @@ -114,6 +111,10 @@ 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); @@ -135,10 +136,13 @@ void loop() { // 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 < OUTPUT_COUNT; i++) { + 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(); } @@ -150,7 +154,7 @@ void loop() { void HandleIntClockTick(uint32_t tick) { bool refresh = false; - for (int i = 0; i < OUTPUT_COUNT; i++) { + for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { app.channel[i].processClockTick(tick, gravity.outputs[i]); if (app.channel[i].isCvModActive()) { @@ -191,6 +195,25 @@ void HandleShiftPressed() { } 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; } @@ -212,12 +235,13 @@ void HandleRotate(Direction dir, int val) { } void HandlePressedRotate(Direction dir, int val) { - if (dir == DIRECTION_INCREMENT && app.selected_channel < OUTPUT_COUNT) { + 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; } @@ -228,15 +252,22 @@ void editMainParameter(int val) { 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, SOURCE_LAST); - app.selected_source = static_cast(source); + updateSelection(source, val, Clock::SOURCE_LAST); + app.selected_source = static_cast(source); gravity.clock.SetSource(app.selected_source); break; } + 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; } } @@ -279,12 +310,18 @@ void updateSelection(int& param, int change, int 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 < OUTPUT_COUNT; i++) { + for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { gravity.outputs[i].Low(); } } @@ -331,7 +368,7 @@ void DisplayMainPage() { switch (app.selected_param) { case PARAM_MAIN_TEMPO: // Serial MIDI is too unstable to display bpm in real time. - if (app.selected_source == SOURCE_EXTERNAL_MIDI) { + if (app.selected_source == Clock::SOURCE_EXTERNAL_MIDI) { sprintf(mainText, "%s", "EXT"); } else { sprintf(mainText, "%d", gravity.clock.Tempo()); @@ -340,30 +377,39 @@ void DisplayMainPage() { break; case PARAM_MAIN_SOURCE: switch (app.selected_source) { - case SOURCE_INTERNAL: + case Clock::SOURCE_INTERNAL: sprintf(mainText, "%s", "INT"); subText = "CLOCK"; break; - case SOURCE_EXTERNAL_PPQN_24: + case Clock::SOURCE_EXTERNAL_PPQN_24: sprintf(mainText, "%s", "EXT"); subText = "24 PPQN"; break; - case SOURCE_EXTERNAL_PPQN_4: + case Clock::SOURCE_EXTERNAL_PPQN_4: sprintf(mainText, "%s", "EXT"); subText = "4 PPQN"; break; - case SOURCE_EXTERNAL_MIDI: + case Clock::SOURCE_EXTERNAL_MIDI: sprintf(mainText, "%s", "EXT"); subText = "MIDI"; break; } + 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"}; + const char* menu_items[PARAM_MAIN_LAST] = {"TEMPO", "SOURCE", "ENCODER DIR", "RESET"}; drawMenuItems(menu_items, PARAM_MAIN_LAST); } @@ -469,7 +515,7 @@ void DisplaySelectedChannel() { gravity.display.drawHLine(1, boxY, SCREEN_WIDTH - 2); gravity.display.drawVLine(SCREEN_WIDTH - 2, boxY, boxHeight); - for (int i = 0; i < OUTPUT_COUNT + 1; i++) { + for (int i = 0; i < Gravity::OUTPUT_COUNT + 1; i++) { // Draw box frame or filled selected box. gravity.display.setDrawColor(1); (app.selected_channel == i) diff --git a/examples/Gravity/app_state.h b/examples/Gravity/app_state.h new file mode 100644 index 0000000..a70661a --- /dev/null +++ b/examples/Gravity/app_state.h @@ -0,0 +1,21 @@ +#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; + bool encoder_reversed = false; + bool refresh_screen = true; + bool editing_param = false; + int selected_param = 0; + int selected_sub_param = 0; + byte selected_channel = 0; // 0=tempo, 1-6=output channel + Clock::Source selected_source = Clock::SOURCE_INTERNAL; + Channel channel[Gravity::OUTPUT_COUNT]; +}; + +#endif // APP_STATE_H \ No newline at end of file diff --git a/examples/Gravity/channel.h b/examples/Gravity/channel.h index a558adf..f5999b7 100644 --- a/examples/Gravity/channel.h +++ b/examples/Gravity/channel.h @@ -30,10 +30,24 @@ static const int clock_mod_pulses[MOD_CHOICE_SIZE] = {4, 8, 12, 16, 24, 32, 48, class Channel { public: Channel() { + Init(); + } + + void Init() { + // Reset base values to their defaults + base_clock_mod_index = 7; + base_probability = 100; + base_duty_cycle = 50; + base_offset = 0; + cv_source = CV_NONE; + cv_destination = CV_DEST_NONE; + cvmod_clock_mod_index = base_clock_mod_index; cvmod_probability = base_probability; cvmod_duty_cycle = base_duty_cycle; cvmod_offset = base_offset; + duty_cycle_pulses = 0; + offset_pulses = 0; } // Setters (Set the BASE value) @@ -121,11 +135,11 @@ class Channel { } private: - // User-settable "base" values. - byte base_clock_mod_index = 7; - byte base_probability = 100; - byte base_duty_cycle = 50; - byte base_offset = 0; + // User-settable base values. + byte base_clock_mod_index; + byte base_probability; + byte base_duty_cycle; + byte base_offset; // Base value with cv mod applied. byte cvmod_clock_mod_index; @@ -133,8 +147,8 @@ class Channel { byte cvmod_duty_cycle; byte cvmod_offset; - int duty_cycle_pulses; - int offset_pulses; + uint32_t duty_cycle_pulses; + uint32_t offset_pulses; // CV configuration CvSource cv_source = CV_NONE; diff --git a/examples/Gravity/save_state.cpp b/examples/Gravity/save_state.cpp new file mode 100644 index 0000000..c214c63 --- /dev/null +++ b/examples/Gravity/save_state.cpp @@ -0,0 +1,119 @@ +#include "save_state.h" + +#include + +#include "app_state.h" + +StateManager::StateManager() : _isDirty(false), _lastChangeTime(0) {} + +bool StateManager::initialize(AppState& app) { + if (isDataValid()) { + static EepromData load_data; + EEPROM.get(sizeof(Metadata), load_data); + + // Restore main app state + app.tempo = load_data.tempo; + app.encoder_reversed = load_data.encoder_reversed; + app.selected_param = load_data.selected_param; + app.selected_channel = load_data.selected_channel; + app.selected_source = static_cast(load_data.selected_source); + + // Loop through and restore each channel's state. + 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.setCvSource(static_cast(saved_ch_state.cv_source)); + ch.setCvDestination(static_cast(saved_ch_state.cv_destination)); + } + + return true; + } else { + reset(app); + return false; + } +} + +void StateManager::save(const AppState& app) { + // Ensure interrupts do not cause corrupt data writes. + noInterrupts(); + _save_worker(app); + interrupts(); +} + +void StateManager::reset(AppState& app) { + app.tempo = Clock::DEFAULT_TEMPO; + app.encoder_reversed = false; + app.selected_param = 0; + app.selected_channel = 0; + app.selected_source = Clock::SOURCE_INTERNAL; + + for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { + app.channel[i].Init(); + } + + noInterrupts(); + _metadata_worker(); // Write the new metadata + _save_worker(app); // Write the new (default) app state + interrupts(); + + _isDirty = false; +} + +void StateManager::update(const AppState& app) { + // Check if a save is pending and if enough time has passed. + if (_isDirty && (millis() - _lastChangeTime > SAVE_DELAY_MS)) { + save(app); + _isDirty = false; // Clear the flag, we are now "clean". + } +} + +void StateManager::markDirty() { + _isDirty = true; + _lastChangeTime = millis(); +} + +bool StateManager::isDataValid() { + Metadata load_meta; + EEPROM.get(0, load_meta); + bool nameMatch = (strcmp(load_meta.sketchName, CURRENT_SKETCH_NAME) == 0); + bool versionMatch = (load_meta.version == CURRENT_SKETCH_VERSION); + return nameMatch && versionMatch; +} + +void StateManager::_save_worker(const AppState& app) { + static EepromData save_data; + + // Populate main app state + save_data.tempo = app.tempo; + save_data.encoder_reversed = app.encoder_reversed; + save_data.selected_param = app.selected_param; + save_data.selected_channel = app.selected_channel; + save_data.selected_source = static_cast(app.selected_source); + + // Loop through and populate each channel's state + for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { + const auto& ch = app.channel[i]; + auto& save_ch = save_data.channel_data[i]; + + // Use the getters with 'withCvMod = false' to get the base values + 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.cv_source = static_cast(ch.getCvSource()); + save_ch.cv_destination = static_cast(ch.getCvDestination()); + } + EEPROM.put(sizeof(Metadata), save_data); +} + +void StateManager::_metadata_worker() { + Metadata currentMeta; + strcpy(currentMeta.sketchName, CURRENT_SKETCH_NAME); + currentMeta.version = CURRENT_SKETCH_VERSION; + EEPROM.put(0, currentMeta); +} diff --git a/examples/Gravity/save_state.h b/examples/Gravity/save_state.h new file mode 100644 index 0000000..5ca8823 --- /dev/null +++ b/examples/Gravity/save_state.h @@ -0,0 +1,65 @@ +#ifndef SAVE_STATE_H +#define SAVE_STATE_H + +#include +#include + +// Forward-declare AppState to avoid circular dependencies. +struct AppState; + +// Define the constants for the current firmware. +const char CURRENT_SKETCH_NAME[] = "Gravity"; +const float CURRENT_SKETCH_VERSION = 0.2f; + +/** + * @brief Manages saving and loading of the application state to and from EEPROM. + */ +class StateManager { + public: + StateManager(); + + // Populate the AppState instance with values from EEPROM if they exist. + bool initialize(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(); + + private: + // This struct holds the data that identifies the firmware version. + struct Metadata { + char sketchName[16]; + byte version; + }; + struct ChannelState { + byte base_clock_mod_index; + byte base_probability; + byte base_duty_cycle; + byte base_offset; + byte cv_source; // Cast the CvSource enum to a byte for storage + byte cv_destination; // Cast the CvDestination enum as a byte for storage + }; + // This struct holds all the parameters we want to save. + struct EepromData { + int tempo; + bool encoder_reversed; + byte selected_param; + byte selected_channel; + byte selected_source; + ChannelState channel_data[Gravity::OUTPUT_COUNT]; + }; + + void save(const AppState& app); + + bool isDataValid(); + void _save_worker(const AppState& app); + void _metadata_worker(); + + bool _isDirty; + unsigned long _lastChangeTime; + static const unsigned long SAVE_DELAY_MS = 2000; +}; + +#endif // SAVE_STATE_H \ No newline at end of file diff --git a/gravity.cpp b/gravity.cpp index 00efe16..3d287a9 100644 --- a/gravity.cpp +++ b/gravity.cpp @@ -11,6 +11,10 @@ #include "gravity.h" +// Initialize the static pointer for the EncoderDir class to null. We want to +// have a static pointer to decouple the ISR from the global gravity object. +EncoderDir* EncoderDir::_instance = nullptr; + void Gravity::Init() { initClock(); initInputs(); @@ -68,18 +72,13 @@ void Gravity::Process() { } } -void ReadEncoder() { - gravity.encoder.UpdateEncoder(); -} - -// Define Encoder pin ISR. -// Pin Change Interrupt on Port C (D17/A3). -ISR(PCINT2_vect) { - ReadEncoder(); -}; // Pin Change Interrupt on Port D (D4). +ISR(PCINT2_vect) { + EncoderDir::isr(); +}; +// Pin Change Interrupt on Port C (D17/A3). ISR(PCINT1_vect) { - ReadEncoder(); + EncoderDir::isr(); }; // Singleton diff --git a/gravity.h b/gravity.h index 9bba82d..2002616 100644 --- a/gravity.h +++ b/gravity.h @@ -14,6 +14,8 @@ // Hardware abstraction wrapper for the Gravity module. class Gravity { public: + static const uint8_t OUTPUT_COUNT = 6; + // Constructor Gravity() : display(U8G2_R2, SCL, SDA, U8X8_PIN_NONE) {} @@ -27,7 +29,7 @@ class Gravity { // Polling check for state change of inputs and outputs. void Process(); - U8G2_SSD1306_128X64_NONAME_2_HW_I2C display; // OLED display object. + U8G2_SSD1306_128X64_NONAME_1_HW_I2C display; // OLED display object. Clock clock; // Clock source wrapper. DigitalOutput outputs[OUTPUT_COUNT]; // An array containing each Output object. EncoderDir encoder; // Rotary encoder with button instance diff --git a/peripherials.h b/peripherials.h index 8f5b68d..5c4ec91 100644 --- a/peripherials.h +++ b/peripherials.h @@ -39,6 +39,4 @@ #define OUT_CH5 9 #define OUT_CH6 11 -const uint8_t OUTPUT_COUNT = 6; - #endif -- 2.39.5 From 8a9bf121dc534f95e3977780dec78609cc36cef1 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sun, 15 Jun 2025 21:18:35 -0700 Subject: [PATCH 21/69] remove unused fields --- examples/Gravity/channel.h | 7 ------- 1 file changed, 7 deletions(-) diff --git a/examples/Gravity/channel.h b/examples/Gravity/channel.h index f5999b7..b7115a3 100644 --- a/examples/Gravity/channel.h +++ b/examples/Gravity/channel.h @@ -46,8 +46,6 @@ class Channel { cvmod_probability = base_probability; cvmod_duty_cycle = base_duty_cycle; cvmod_offset = base_offset; - duty_cycle_pulses = 0; - offset_pulses = 0; } // Setters (Set the BASE value) @@ -68,8 +66,6 @@ class Channel { int getOffset(bool withCvMod = false) const { return withCvMod ? cvmod_offset : base_offset; } int getClockMod(bool withCvMod = false) const { return clock_mod[getClockModIndex(withCvMod)]; } int getClockModIndex(bool withCvMod = false) const { return withCvMod ? cvmod_clock_mod_index : base_clock_mod_index; } - uint32_t getDutyCyclePulses() const { return duty_cycle_pulses; } - uint32_t getOffsetPulses() const { return offset_pulses; } CvSource getCvSource() { return cv_source; } CvDestination getCvDestination() { return cv_destination; } bool isCvModActive() const { return cv_source != CV_NONE && cv_destination != CV_DEST_NONE; } @@ -147,9 +143,6 @@ class Channel { byte cvmod_duty_cycle; byte cvmod_offset; - uint32_t duty_cycle_pulses; - uint32_t offset_pulses; - // CV configuration CvSource cv_source = CV_NONE; CvDestination cv_destination = CV_DEST_NONE; -- 2.39.5 From 6c75a85929beee354786097fd64495b0cd38f964 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Tue, 17 Jun 2025 06:59:55 -0700 Subject: [PATCH 22/69] fixed type mismatch in default state version. --- examples/Gravity/save_state.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/Gravity/save_state.h b/examples/Gravity/save_state.h index 5ca8823..184b3f8 100644 --- a/examples/Gravity/save_state.h +++ b/examples/Gravity/save_state.h @@ -9,7 +9,7 @@ struct AppState; // Define the constants for the current firmware. const char CURRENT_SKETCH_NAME[] = "Gravity"; -const float CURRENT_SKETCH_VERSION = 0.2f; +const byte CURRENT_SKETCH_VERSION = 3; /** * @brief Manages saving and loading of the application state to and from EEPROM. -- 2.39.5 From 966f5b767bf70ca6c3fc5b8ec6f10b5ab1b2cf3b Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Tue, 17 Jun 2025 07:27:22 -0700 Subject: [PATCH 23/69] code cleanup for consistency --- examples/Gravity/save_state.cpp | 32 ++++++++++++++++---------------- examples/Gravity/save_state.h | 19 ++++++++++--------- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/examples/Gravity/save_state.cpp b/examples/Gravity/save_state.cpp index c214c63..87357cb 100644 --- a/examples/Gravity/save_state.cpp +++ b/examples/Gravity/save_state.cpp @@ -7,7 +7,7 @@ StateManager::StateManager() : _isDirty(false), _lastChangeTime(0) {} bool StateManager::initialize(AppState& app) { - if (isDataValid()) { + if (_isDataValid()) { static EepromData load_data; EEPROM.get(sizeof(Metadata), load_data); @@ -38,10 +38,10 @@ bool StateManager::initialize(AppState& app) { } } -void StateManager::save(const AppState& app) { +void StateManager::_save(const AppState& app) { // Ensure interrupts do not cause corrupt data writes. noInterrupts(); - _save_worker(app); + _saveState(app); interrupts(); } @@ -57,8 +57,8 @@ void StateManager::reset(AppState& app) { } noInterrupts(); - _metadata_worker(); // Write the new metadata - _save_worker(app); // Write the new (default) app state + _saveMetadata(); // Write the new metadata + _saveState(app); // Write the new (default) app state interrupts(); _isDirty = false; @@ -67,7 +67,7 @@ void StateManager::reset(AppState& app) { void StateManager::update(const AppState& app) { // Check if a save is pending and if enough time has passed. if (_isDirty && (millis() - _lastChangeTime > SAVE_DELAY_MS)) { - save(app); + _save(app); _isDirty = false; // Clear the flag, we are now "clean". } } @@ -77,15 +77,15 @@ void StateManager::markDirty() { _lastChangeTime = millis(); } -bool StateManager::isDataValid() { +bool StateManager::_isDataValid() { Metadata load_meta; EEPROM.get(0, load_meta); - bool nameMatch = (strcmp(load_meta.sketchName, CURRENT_SKETCH_NAME) == 0); - bool versionMatch = (load_meta.version == CURRENT_SKETCH_VERSION); - return nameMatch && versionMatch; + bool name_match = (strcmp(load_meta.sketch_name, SKETCH_NAME) == 0); + bool version_match = (load_meta.version == SKETCH_VERSION); + return name_match && version_match; } -void StateManager::_save_worker(const AppState& app) { +void StateManager::_saveState(const AppState& app) { static EepromData save_data; // Populate main app state @@ -111,9 +111,9 @@ void StateManager::_save_worker(const AppState& app) { EEPROM.put(sizeof(Metadata), save_data); } -void StateManager::_metadata_worker() { - Metadata currentMeta; - strcpy(currentMeta.sketchName, CURRENT_SKETCH_NAME); - currentMeta.version = CURRENT_SKETCH_VERSION; - EEPROM.put(0, currentMeta); +void StateManager::_saveMetadata() { + Metadata current_meta; + strcpy(current_meta.sketch_name, SKETCH_NAME); + current_meta.version = SKETCH_VERSION; + EEPROM.put(0, current_meta); } diff --git a/examples/Gravity/save_state.h b/examples/Gravity/save_state.h index 184b3f8..b747189 100644 --- a/examples/Gravity/save_state.h +++ b/examples/Gravity/save_state.h @@ -8,8 +8,11 @@ struct AppState; // Define the constants for the current firmware. -const char CURRENT_SKETCH_NAME[] = "Gravity"; -const byte CURRENT_SKETCH_VERSION = 3; +const char SKETCH_NAME[] = "Gravity"; +const byte SKETCH_VERSION = 3; + +// Define the minimum amount of time between EEPROM writes. +static const unsigned long SAVE_DELAY_MS = 2000; /** * @brief Manages saving and loading of the application state to and from EEPROM. @@ -30,7 +33,7 @@ class StateManager { private: // This struct holds the data that identifies the firmware version. struct Metadata { - char sketchName[16]; + char sketch_name[16]; byte version; }; struct ChannelState { @@ -51,15 +54,13 @@ class StateManager { ChannelState channel_data[Gravity::OUTPUT_COUNT]; }; - void save(const AppState& app); - - bool isDataValid(); - void _save_worker(const AppState& app); - void _metadata_worker(); + void _save(const AppState& app); + bool _isDataValid(); + void _saveState(const AppState& app); + void _saveMetadata(); bool _isDirty; unsigned long _lastChangeTime; - static const unsigned long SAVE_DELAY_MS = 2000; }; #endif // SAVE_STATE_H \ No newline at end of file -- 2.39.5 From 54999d6525a3c470ec0879dbbfa36f862a086277 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Tue, 17 Jun 2025 20:40:51 -0700 Subject: [PATCH 24/69] Separate display into its own file. --- examples/Gravity/Gravity.ino | 341 +--------------------------------- examples/Gravity/app_state.h | 24 +++ examples/Gravity/display.h | 346 +++++++++++++++++++++++++++++++++++ gravity.cpp | 2 +- 4 files changed, 372 insertions(+), 341 deletions(-) create mode 100644 examples/Gravity/display.h diff --git a/examples/Gravity/Gravity.ino b/examples/Gravity/Gravity.ino index f3502ea..5869a92 100644 --- a/examples/Gravity/Gravity.ino +++ b/examples/Gravity/Gravity.ino @@ -22,87 +22,11 @@ #include "app_state.h" #include "channel.h" #include "save_state.h" +#include "display.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. // @@ -316,271 +240,8 @@ void InitAppState(AppState& app) { 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); -} \ No newline at end of file diff --git a/examples/Gravity/app_state.h b/examples/Gravity/app_state.h index a70661a..09cd256 100644 --- a/examples/Gravity/app_state.h +++ b/examples/Gravity/app_state.h @@ -18,4 +18,28 @@ struct AppState { Channel channel[Gravity::OUTPUT_COUNT]; }; +extern AppState app; + +static Channel& GetSelectedChannel() { + return app.channel[app.selected_channel - 1]; +} + +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, +}; + #endif // APP_STATE_H \ No newline at end of file diff --git a/examples/Gravity/display.h b/examples/Gravity/display.h new file mode 100644 index 0000000..3291238 --- /dev/null +++ b/examples/Gravity/display.h @@ -0,0 +1,346 @@ +#ifndef DISPLAY_H +#define DISPLAY_H + +#include + +#include "app_state.h" + +// +// UI Display functions for drawing the UI to the OLED display. +// + +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}; + +// 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; + +// 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); +} + +void drawSelectHero() { + gravity.display.setDrawColor(1); + const int tickSize = 3; + const int heroWidth = SCREEN_WIDTH/2; + const int heroHeight = 49; + gravity.display.drawLine(0, 0, tickSize, 0); + gravity.display.drawLine(0, 0, 0, tickSize); + gravity.display.drawLine(heroWidth, 0, heroWidth-tickSize, 0); + gravity.display.drawLine(heroWidth, 0, heroWidth, tickSize); + gravity.display.drawLine(heroWidth, heroHeight, heroWidth, heroHeight-tickSize); + gravity.display.drawLine(heroWidth, heroHeight, heroWidth-tickSize, heroHeight); + gravity.display.drawLine(0, heroHeight, tickSize, heroHeight); + gravity.display.drawLine(0, heroHeight, 0, heroHeight-tickSize); + gravity.display.setDrawColor(2); +} + +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; + + if (app.editing_param) { + gravity.display.drawBox(boxX, boxY, boxWidth, boxHeight); + drawSelectHero(); + } 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], MENU_ITEM_HEIGHT * (i + 1) - 1); + } +} + +// Main display functions + +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 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()); +} + +#endif // DISPLAY_H diff --git a/gravity.cpp b/gravity.cpp index 3d287a9..159dc6f 100644 --- a/gravity.cpp +++ b/gravity.cpp @@ -81,5 +81,5 @@ ISR(PCINT1_vect) { EncoderDir::isr(); }; -// Singleton +// Global instance Gravity gravity; -- 2.39.5 From c8e42c744873c7d27eea7e0f0e99e6d2da8a9793 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 21 Jun 2025 15:45:11 -0700 Subject: [PATCH 25/69] use appropriate string width method. --- examples/Gravity/display.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/Gravity/display.h b/examples/Gravity/display.h index 3291238..b9994ed 100644 --- a/examples/Gravity/display.h +++ b/examples/Gravity/display.h @@ -82,13 +82,13 @@ constexpr int CHANNEL_BOX_HEIGHT = 14; // 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); + 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.getUTF8Width(text); + int textWidth = gravity.display.getStrWidth(text); int drawX = (SCREEN_WIDTH - textWidth) - MENU_BOX_PADDING; gravity.display.drawStr(drawX, y, text); } -- 2.39.5 From 973c13b8efdc3bdd2d4f9feaaad2378f5adf7abe Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sun, 22 Jun 2025 18:38:51 +0000 Subject: [PATCH 26/69] Add per-channel Swing configuration (#7) Select swing amount from a percentage range of the beat starting a 50% (unchanged) to a max swing amount of 95% (about 1/32nd note before end of period). Swing percentage shows an indicator marker when the percentage lines up with a quantized note on the grid. This is probably going to be the last feature because it is pushing up against the limits of available dynamic memory. Out of scope changes: - selecting parameters / values no longer wraps - reduce dynamic memory used in processClockTick - various readability formatting Reviewed-on: https://git.pinkduck.xyz/adam/libGravity/pulls/7 Co-authored-by: Adam Wonak Co-committed-by: Adam Wonak --- examples/Gravity/Gravity.ino | 8 ++-- examples/Gravity/app_state.h | 2 + examples/Gravity/channel.h | 71 +++++++++++++++++++++++---------- examples/Gravity/display.h | 41 ++++++++++++++++--- examples/Gravity/save_state.cpp | 2 + examples/Gravity/save_state.h | 3 +- 6 files changed, 95 insertions(+), 32 deletions(-) diff --git a/examples/Gravity/Gravity.ino b/examples/Gravity/Gravity.ino index 5869a92..8b2234c 100644 --- a/examples/Gravity/Gravity.ino +++ b/examples/Gravity/Gravity.ino @@ -21,8 +21,8 @@ #include "app_state.h" #include "channel.h" -#include "save_state.h" #include "display.h" +#include "save_state.h" AppState app; StateManager stateManager; @@ -210,6 +210,9 @@ void editChannelParameter(int val) { case PARAM_CH_OFFSET: ch.setOffset(ch.getOffset() + val); break; + case PARAM_CH_SWING: + ch.setSwing(ch.getSwing() + val); + break; case PARAM_CH_CV_SRC: { int source = static_cast(ch.getCvSource()); updateSelection(source, val, CV_LAST); @@ -226,8 +229,7 @@ void editChannelParameter(int val) { } void updateSelection(int& param, int change, int maxValue) { - // This formula correctly handles positive and negative wrapping. - param = (param + change % maxValue + maxValue) % maxValue; + param = constrain(param + change, 0, maxValue - 1); } // diff --git a/examples/Gravity/app_state.h b/examples/Gravity/app_state.h index 09cd256..bb9f662 100644 --- a/examples/Gravity/app_state.h +++ b/examples/Gravity/app_state.h @@ -14,6 +14,7 @@ struct AppState { int selected_param = 0; int selected_sub_param = 0; byte selected_channel = 0; // 0=tempo, 1-6=output channel + byte selected_shuffle = 0; Clock::Source selected_source = Clock::SOURCE_INTERNAL; Channel channel[Gravity::OUTPUT_COUNT]; }; @@ -37,6 +38,7 @@ enum ParamsChannelPage { PARAM_CH_PROB, PARAM_CH_DUTY, PARAM_CH_OFFSET, + PARAM_CH_SWING, PARAM_CH_CV_SRC, PARAM_CH_CV_DEST, PARAM_CH_LAST, diff --git a/examples/Gravity/channel.h b/examples/Gravity/channel.h index b7115a3..3567643 100644 --- a/examples/Gravity/channel.h +++ b/examples/Gravity/channel.h @@ -18,6 +18,7 @@ enum CvDestination { CV_DEST_PROB, CV_DEST_DUTY, CV_DEST_OFFSET, + CV_DEST_SWING, CV_DEST_LAST, }; @@ -39,6 +40,7 @@ class Channel { base_probability = 100; base_duty_cycle = 50; base_offset = 0; + base_swing = 50; cv_source = CV_NONE; cv_destination = CV_DEST_NONE; @@ -46,6 +48,7 @@ class Channel { cvmod_probability = base_probability; cvmod_duty_cycle = base_duty_cycle; cvmod_offset = base_offset; + cvmod_swing = base_swing; } // Setters (Set the BASE value) @@ -55,7 +58,8 @@ class Channel { } void setProbability(int prob) { base_probability = constrain(prob, 0, 100); } void setDutyCycle(int duty) { base_duty_cycle = constrain(duty, 1, 99); } - void setOffset(int off) { base_offset = constrain(off, 0, 100); } + void setOffset(int off) { base_offset = constrain(off, 0, 99); } + void setSwing(int val) { base_swing = constrain(val, 50, 95); } void setCvSource(CvSource source) { cv_source = source; } void setCvDestination(CvDestination dest) { cv_destination = dest; } @@ -64,6 +68,7 @@ class Channel { 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 clock_mod[getClockModIndex(withCvMod)]; } int getClockModIndex(bool withCvMod = false) const { return withCvMod ? cvmod_clock_mod_index : base_clock_mod_index; } CvSource getCvSource() { return cv_source; } @@ -77,21 +82,31 @@ class Channel { */ void processClockTick(uint32_t tick, DigitalOutput& output) { // Calculate output duty cycle state using cv modded values to determine pulse counts. - const uint32_t mod_pulses = clock_mod_pulses[cvmod_clock_mod_index]; - const uint32_t duty_pulses = max((long)((mod_pulses * (100L - cvmod_duty_cycle)) / 100L), 1L); - const uint32_t offset_pulses = (long)((mod_pulses * (100L - cvmod_offset)) / 100L); + const uint16_t mod_pulses = clock_mod_pulses[cvmod_clock_mod_index]; + const uint16_t duty_pulses = max((long)((mod_pulses * (100L - cvmod_duty_cycle)) / 100L), 1L); + const uint16_t offset_pulses = (long)((mod_pulses * (100L - cvmod_offset)) / 100L); - const uint32_t current_tick_offset = tick + offset_pulses; + uint16_t swing_pulses = 0; + // Check step increment for odd beats. + if (cvmod_swing > 50 && (tick / mod_pulses) % 2 == 1) { + int shifted_swing = cvmod_swing - 50; + swing_pulses = (long)((mod_pulses * (100L - shifted_swing)) / 100L); + } - // Duty cycle high check - if (current_tick_offset % mod_pulses == 0) { - if (cvmod_probability >= random(0, 100)) { - output.High(); + const uint32_t current_tick_offset = tick + offset_pulses + swing_pulses; + + // Step check + if (!output.On()) { + if (current_tick_offset % mod_pulses == 0) { + // Duty cycle high check + if (cvmod_probability >= random(0, 100)) { + output.High(); + } } } // Duty cycle low check - const uint32_t duty_cycle_end_tick = tick + duty_pulses + offset_pulses; + const uint32_t duty_cycle_end_tick = tick + duty_pulses + offset_pulses + swing_pulses; if (duty_cycle_end_tick % mod_pulses == 0) { output.Low(); } @@ -104,6 +119,7 @@ class Channel { cvmod_probability = base_probability; cvmod_duty_cycle = base_duty_cycle; cvmod_offset = base_offset; + cvmod_swing = base_swing; return; } @@ -113,21 +129,30 @@ class Channel { // Calculate and store cv modded values using bipolar mapping. // Default to base value if not the current CV destination. - cvmod_clock_mod_index = (cv_destination == CV_DEST_MOD) - ? constrain(base_clock_mod_index + map(value, -512, 512, -10, 10), 0, MOD_CHOICE_SIZE - 1) - : base_clock_mod_index; + cvmod_clock_mod_index = + (cv_destination == CV_DEST_MOD) + ? constrain(base_clock_mod_index + map(value, -512, 512, -10, 10), 0, MOD_CHOICE_SIZE - 1) + : base_clock_mod_index; - cvmod_probability = (cv_destination == CV_DEST_PROB) - ? constrain(base_probability + map(value, -512, 512, -50, 50), 0, 100) - : base_probability; + cvmod_probability = + (cv_destination == CV_DEST_PROB) + ? constrain(base_probability + map(value, -512, 512, -50, 50), 0, 100) + : base_probability; - cvmod_duty_cycle = (cv_destination == CV_DEST_DUTY) - ? constrain(base_duty_cycle + map(value, -512, 512, -50, 50), 1, 99) - : base_duty_cycle; + cvmod_duty_cycle = + (cv_destination == CV_DEST_DUTY) + ? constrain(base_duty_cycle + map(value, -512, 512, -50, 50), 1, 99) + : base_duty_cycle; - cvmod_offset = (cv_destination == CV_DEST_OFFSET) - ? constrain(base_offset + map(value, -512, 512, -50, 50), 0, 99) - : base_offset; + cvmod_offset = + (cv_destination == CV_DEST_OFFSET) + ? constrain(base_offset + map(value, -512, 512, -50, 50), 0, 99) + : base_offset; + + cvmod_swing = + (cv_destination == CV_DEST_SWING) + ? constrain(base_swing + map(value, -512, 512, -25, 25), 50, 95) + : base_swing; } private: @@ -136,12 +161,14 @@ class Channel { byte base_probability; byte base_duty_cycle; byte base_offset; + byte base_swing; // 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 configuration CvSource cv_source = CV_NONE; diff --git a/examples/Gravity/display.h b/examples/Gravity/display.h index b9994ed..6cce08e 100644 --- a/examples/Gravity/display.h +++ b/examples/Gravity/display.h @@ -96,16 +96,16 @@ void drawRightAlignedText(const char* text, int y) { void drawSelectHero() { gravity.display.setDrawColor(1); const int tickSize = 3; - const int heroWidth = SCREEN_WIDTH/2; + const int heroWidth = SCREEN_WIDTH / 2; const int heroHeight = 49; gravity.display.drawLine(0, 0, tickSize, 0); gravity.display.drawLine(0, 0, 0, tickSize); - gravity.display.drawLine(heroWidth, 0, heroWidth-tickSize, 0); + gravity.display.drawLine(heroWidth, 0, heroWidth - tickSize, 0); gravity.display.drawLine(heroWidth, 0, heroWidth, tickSize); - gravity.display.drawLine(heroWidth, heroHeight, heroWidth, heroHeight-tickSize); - gravity.display.drawLine(heroWidth, heroHeight, heroWidth-tickSize, heroHeight); + gravity.display.drawLine(heroWidth, heroHeight, heroWidth, heroHeight - tickSize); + gravity.display.drawLine(heroWidth, heroHeight, heroWidth - tickSize, heroHeight); gravity.display.drawLine(0, heroHeight, tickSize, heroHeight); - gravity.display.drawLine(0, heroHeight, 0, heroHeight-tickSize); + gravity.display.drawLine(0, heroHeight, 0, heroHeight - tickSize); gravity.display.setDrawColor(2); } @@ -147,6 +147,24 @@ void drawMenuItems(const char* menu_items[], int menu_size) { } } +// 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 + gravity.display.drawBox(56, 4, 4, 4); + break; + case 54: // 1/32nd tripplet + case 62: // 1/16th tripplet + case 71: // 1/8th tripplet + gravity.display.drawBox(56, 4, 4, 4); + gravity.display.drawBox(57, 5, 2, 2); + break; + } +} + // Main display functions void DisplayMainPage() { @@ -244,6 +262,13 @@ void DisplayChannelPage() { sprintf(mainText, "%d%%", ch.getOffset(withCvMod)); subText = "SHIFT HIT"; break; + case PARAM_CH_SWING: + ch.getSwing() == 50 + ? sprintf(mainText, "OFF") + : sprintf(mainText, "%d%%", ch.getSwing(withCvMod)); + subText = "DOWN BEAT"; + swingDivisionMark(); + break; case PARAM_CH_CV_SRC: { switch (ch.getCvSource()) { case CV_NONE: @@ -283,6 +308,10 @@ void DisplayChannelPage() { sprintf(mainText, "DEST"); subText = "OFFSET"; break; + case CV_DEST_SWING: + sprintf(mainText, "DEST"); + subText = "SWING"; + break; } break; } @@ -293,7 +322,7 @@ void DisplayChannelPage() { // Draw Channel Page menu items const char* menu_items[PARAM_CH_LAST] = { - "MOD", "PROBABILITY", "DUTY", "OFFSET", "CV SOURCE", "CV DEST"}; + "MOD", "PROBABILITY", "DUTY", "OFFSET", "SWING", "CV SOURCE", "CV DEST"}; drawMenuItems(menu_items, PARAM_CH_LAST); } diff --git a/examples/Gravity/save_state.cpp b/examples/Gravity/save_state.cpp index 87357cb..f7529ed 100644 --- a/examples/Gravity/save_state.cpp +++ b/examples/Gravity/save_state.cpp @@ -27,6 +27,7 @@ bool StateManager::initialize(AppState& app) { 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_shuffle); ch.setCvSource(static_cast(saved_ch_state.cv_source)); ch.setCvDestination(static_cast(saved_ch_state.cv_destination)); } @@ -105,6 +106,7 @@ void StateManager::_saveState(const AppState& app) { 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_shuffle = ch.getSwing(); save_ch.cv_source = static_cast(ch.getCvSource()); save_ch.cv_destination = static_cast(ch.getCvDestination()); } diff --git a/examples/Gravity/save_state.h b/examples/Gravity/save_state.h index b747189..eb3c7a2 100644 --- a/examples/Gravity/save_state.h +++ b/examples/Gravity/save_state.h @@ -9,7 +9,7 @@ struct AppState; // Define the constants for the current firmware. const char SKETCH_NAME[] = "Gravity"; -const byte SKETCH_VERSION = 3; +const byte SKETCH_VERSION = 4; // Define the minimum amount of time between EEPROM writes. static const unsigned long SAVE_DELAY_MS = 2000; @@ -41,6 +41,7 @@ class StateManager { byte base_probability; byte base_duty_cycle; byte base_offset; + byte base_shuffle; byte cv_source; // Cast the CvSource enum to a byte for storage byte cv_destination; // Cast the CvDestination enum as a byte for storage }; -- 2.39.5 From 6fa5674909e77de325c3698b56c1bd70818ed5c0 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sun, 22 Jun 2025 18:44:01 +0000 Subject: [PATCH 27/69] Display memory usage reduction (#8) Convert all string references from const char* to String/F() to store values in flash instead of ram. Memory usage from `main`: ``` Sketch uses 27878 bytes (90%) of program storage space. Maximum is 30720 bytes. Global variables use 1755 bytes (85%) of dynamic memory, leaving 293 bytes for local variables. Maximum is 2048 bytes. ``` Memory usage after these changes: ``` Sketch uses 28054 bytes (91%) of program storage space. Maximum is 30720 bytes. Global variables use 1445 bytes (70%) of dynamic memory, leaving 603 bytes for local variables. Maximum is 2048 bytes. ``` This provides a dynamic memory savings of 310 bytes! Reviewed-on: https://git.pinkduck.xyz/adam/libGravity/pulls/8 Co-authored-by: Adam Wonak Co-committed-by: Adam Wonak --- examples/Gravity/display.h | 135 ++++++++++++++++++------------------- 1 file changed, 64 insertions(+), 71 deletions(-) diff --git a/examples/Gravity/display.h b/examples/Gravity/display.h index 6cce08e..e1211bc 100644 --- a/examples/Gravity/display.h +++ b/examples/Gravity/display.h @@ -58,11 +58,11 @@ const PROGMEM uint8_t LARGE_FONT[916] U8G2_FONT_SECTION("stk-l") = #define play_icon_width 14 #define play_icon_height 14 -static const unsigned char play_icon[] = { +static const unsigned char play_icon[] 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[] = { +static const unsigned char pause_icon[] 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}; @@ -93,23 +93,23 @@ void drawRightAlignedText(const char* text, int y) { gravity.display.drawStr(drawX, y, text); } -void drawSelectHero() { +void drawMainSelection() { gravity.display.setDrawColor(1); const int tickSize = 3; - const int heroWidth = SCREEN_WIDTH / 2; - const int heroHeight = 49; + 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(heroWidth, 0, heroWidth - tickSize, 0); - gravity.display.drawLine(heroWidth, 0, heroWidth, tickSize); - gravity.display.drawLine(heroWidth, heroHeight, heroWidth, heroHeight - tickSize); - gravity.display.drawLine(heroWidth, heroHeight, heroWidth - tickSize, heroHeight); - gravity.display.drawLine(0, heroHeight, tickSize, heroHeight); - gravity.display.drawLine(0, heroHeight, 0, heroHeight - 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(const char* menu_items[], int menu_size) { +void drawMenuItems(String menu_items[], int menu_size) { // Draw menu items gravity.display.setFont(TEXT_FONT); @@ -128,7 +128,7 @@ void drawMenuItems(const char* menu_items[], int menu_size) { if (app.editing_param) { gravity.display.drawBox(boxX, boxY, boxWidth, boxHeight); - drawSelectHero(); + drawMainSelection(); } else { gravity.display.drawFrame(boxX, boxY, boxWidth, boxHeight); } @@ -143,7 +143,7 @@ void drawMenuItems(const char* menu_items[], int menu_size) { 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); + drawRightAlignedText(menu_items[idx].c_str(), MENU_ITEM_HEIGHT * (i + 1) - 1); } } @@ -173,54 +173,52 @@ void DisplayMainPage() { gravity.display.setFont(TEXT_FONT); // Display selected editable value - char mainText[8]; - const char* subText; + 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) { - sprintf(mainText, "%s", "EXT"); + mainText = F("EXT"); } else { - sprintf(mainText, "%d", gravity.clock.Tempo()); + mainText = String(gravity.clock.Tempo()); } - subText = "BPM"; + subText = F("BPM"); break; case PARAM_MAIN_SOURCE: + mainText = F("EXT"); switch (app.selected_source) { case Clock::SOURCE_INTERNAL: - sprintf(mainText, "%s", "INT"); - subText = "CLOCK"; + mainText = F("INT"); + subText = F("CLOCK"); break; case Clock::SOURCE_EXTERNAL_PPQN_24: - sprintf(mainText, "%s", "EXT"); - subText = "24 PPQN"; + subText = F("24 PPQN"); break; case Clock::SOURCE_EXTERNAL_PPQN_4: - sprintf(mainText, "%s", "EXT"); - subText = "4 PPQN"; + subText = F("4 PPQN"); break; case Clock::SOURCE_EXTERNAL_MIDI: - sprintf(mainText, "%s", "EXT"); - subText = "MIDI"; + subText = F("MIDI"); break; } break; case PARAM_MAIN_ENCODER_DIR: - sprintf(mainText, "%s", "DIR"); - subText = app.selected_sub_param == 0 ? "DEFAULT" : "REVERSED"; + mainText = F("DIR"); + subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("REVERSED"); break; case PARAM_MAIN_RESET_STATE: - sprintf(mainText, "%s", "RST"); - subText = app.selected_sub_param == 0 ? "RESET ALL" : "BACK"; + mainText = F("RST"); + subText = app.selected_sub_param == 0 ? F("RESET ALL") : F("BACK"); break; } - drawCenteredText(mainText, MAIN_TEXT_Y, LARGE_FONT); - drawCenteredText(subText, 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 - const char* menu_items[PARAM_MAIN_LAST] = {"TEMPO", "SOURCE", "ENCODER DIR", "RESET"}; + String menu_items[PARAM_MAIN_LAST] = {F("TEMPO"), F("SOURCE"), F("ENCODER DIR"), F("RESET")}; drawMenuItems(menu_items, PARAM_MAIN_LAST); } @@ -231,8 +229,8 @@ void DisplayChannelPage() { gravity.display.setDrawColor(2); // Display selected editable value - char mainText[5]; - const char* subText; + String mainText; + String subText; // When editing a param, just show the base value. When not editing show // the value with cv mod. @@ -242,87 +240,82 @@ void DisplayChannelPage() { case PARAM_CH_MOD: { int mod_value = ch.getClockMod(withCvMod); if (mod_value > 1) { - sprintf(mainText, "/%d", mod_value); - subText = "DIVIDE"; + mainText = F("/"); + mainText += String(mod_value); + subText = F("DIVIDE"); } else { - sprintf(mainText, "x%d", abs(mod_value)); - subText = "MULTIPLY"; + mainText = F("x"); + mainText += String(abs(mod_value)); + subText = F("MULTIPLY"); } break; } case PARAM_CH_PROB: - sprintf(mainText, "%d%%", ch.getProbability(withCvMod)); - subText = "HIT CHANCE"; + mainText = String(ch.getProbability(withCvMod)) + F("%"); + subText = F("HIT CHANCE"); break; case PARAM_CH_DUTY: - sprintf(mainText, "%d%%", ch.getDutyCycle(withCvMod)); - subText = "PULSE WIDTH"; + mainText = String(ch.getDutyCycle(withCvMod)) + F("%"); + subText = F("PULSE WIDTH"); break; case PARAM_CH_OFFSET: - sprintf(mainText, "%d%%", ch.getOffset(withCvMod)); - subText = "SHIFT HIT"; + mainText = String(ch.getOffset(withCvMod)) + F("%"); + subText = F("SHIFT HIT"); break; case PARAM_CH_SWING: ch.getSwing() == 50 - ? sprintf(mainText, "OFF") - : sprintf(mainText, "%d%%", ch.getSwing(withCvMod)); + ? mainText = F("OFF") + : mainText = String(ch.getSwing(withCvMod)) + F("%"); subText = "DOWN BEAT"; swingDivisionMark(); break; case PARAM_CH_CV_SRC: { switch (ch.getCvSource()) { + mainText = F("SRC"); case CV_NONE: - sprintf(mainText, "SRC"); - subText = "NONE"; + subText = F("NONE"); break; case CV_1: - sprintf(mainText, "SRC"); - subText = "CV 1"; + subText = F("CV 1"); break; case CV_2: - sprintf(mainText, "SRC"); - subText = "CV 2"; + subText = F("CV 2"); break; } break; } case PARAM_CH_CV_DEST: { switch (ch.getCvDestination()) { + mainText = F("DEST"); case CV_DEST_NONE: - sprintf(mainText, "DEST"); - subText = "NONE"; + subText = F("NONE"); break; case CV_DEST_MOD: - sprintf(mainText, "DEST"); - subText = "CLOCK MOD"; + subText = F("CLOCK MOD"); break; case CV_DEST_PROB: - sprintf(mainText, "DEST"); - subText = "PROBABILITY"; + subText = F("PROBABILITY"); break; case CV_DEST_DUTY: - sprintf(mainText, "DEST"); - subText = "DUTY CYCLE"; + subText = F("DUTY CYCLE"); break; case CV_DEST_OFFSET: - sprintf(mainText, "DEST"); - subText = "OFFSET"; + subText = F("OFFSET"); break; case CV_DEST_SWING: - sprintf(mainText, "DEST"); - subText = "SWING"; + subText = F("SWING"); break; } break; } } - drawCenteredText(mainText, MAIN_TEXT_Y, LARGE_FONT); - drawCenteredText(subText, 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 - const char* menu_items[PARAM_CH_LAST] = { - "MOD", "PROBABILITY", "DUTY", "OFFSET", "SWING", "CV SOURCE", "CV DEST"}; + String menu_items[PARAM_CH_LAST] = { + F("MOD"), F("PROBABILITY"), F("DUTY"), F("OFFSET"), F("SWING"), F("CV SOURCE"), F("CV DEST")}; drawMenuItems(menu_items, PARAM_CH_LAST); } @@ -349,7 +342,7 @@ void DisplaySelectedChannel() { 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); + 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); -- 2.39.5 From d56355a94b5addf798aea28ded053c7b4175c6ac Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 28 Jun 2025 09:45:50 -0700 Subject: [PATCH 28/69] refactor encoder. no need for Dir enum. --- encoder_dir.h => encoder.h | 39 +++++++----------------------------- examples/Gravity/app_state.h | 4 ++-- gravity.cpp | 6 +++--- gravity.h | 4 ++-- 4 files changed, 14 insertions(+), 39 deletions(-) rename encoder_dir.h => encoder.h (72%) diff --git a/encoder_dir.h b/encoder.h similarity index 72% rename from encoder_dir.h rename to encoder.h index 5375b57..e9a8635 100644 --- a/encoder_dir.h +++ b/encoder.h @@ -16,28 +16,21 @@ #include "button.h" #include "peripherials.h" -enum Direction { - DIRECTION_UNCHANGED, - DIRECTION_INCREMENT, - DIRECTION_DECREMENT, -}; - -class EncoderDir { +class Encoder { protected: typedef void (*CallbackFunction)(void); - typedef void (*RotateCallbackFunction)(Direction dir, int val); + typedef void (*RotateCallbackFunction)(int val); CallbackFunction on_press; RotateCallbackFunction on_press_rotate; RotateCallbackFunction on_rotate; int change; - Direction dir; public: - EncoderDir() : encoder_(ENCODER_PIN1, ENCODER_PIN2, RotaryEncoder::LatchMode::FOUR3), + Encoder() : encoder_(ENCODER_PIN1, ENCODER_PIN2, RotaryEncoder::LatchMode::FOUR3), button_(ENCODER_SW_PIN) { _instance = this; } - ~EncoderDir() {} + ~Encoder() {} // Set to true if the encoder read direction should be reversed. void SetReverseDirection(bool reversed) { @@ -55,12 +48,6 @@ class EncoderDir { on_press_rotate = f; } - // Parse EncoderButton increment direction. - Direction RotateDirection() { - int dir = (int)(encoder_.getDirection()); - return rotate_(dir, reversed_); - } - void Process() { // Get encoder position change amount. int encoder_rotated = _rotate_change() != 0; @@ -70,9 +57,9 @@ class EncoderDir { // Handle encoder position change and button press. if (button_pressed && encoder_rotated) { rotated_while_held_ = true; - if (on_press_rotate != NULL) on_press_rotate(dir, change); + if (on_press_rotate != NULL) on_press_rotate(change); } else if (!button_pressed && encoder_rotated) { - if (on_rotate != NULL) on_rotate(dir, change); + if (on_rotate != NULL) on_rotate(change); } else if (button_.Change() == Button::CHANGE_RELEASED && !rotated_while_held_) { if (on_press != NULL) on_press(); } @@ -91,7 +78,7 @@ class EncoderDir { } private: - static EncoderDir* _instance; + static Encoder* _instance; int previous_pos_; bool rotated_while_held_; @@ -112,7 +99,6 @@ class EncoderDir { // Update state variables. change = position - previous_pos_; previous_pos_ = position; - dir = RotateDirection(); // Encoder rotate acceleration. if (ms < 16) { @@ -126,17 +112,6 @@ class EncoderDir { } return change; } - - inline Direction rotate_(int dir, bool reversed) { - switch (dir) { - case 1: - return (reversed) ? DIRECTION_DECREMENT : DIRECTION_INCREMENT; - case -1: - return (reversed) ? DIRECTION_INCREMENT : DIRECTION_DECREMENT; - default: - return DIRECTION_UNCHANGED; - } - } }; #endif \ No newline at end of file diff --git a/examples/Gravity/app_state.h b/examples/Gravity/app_state.h index bb9f662..3f78caf 100644 --- a/examples/Gravity/app_state.h +++ b/examples/Gravity/app_state.h @@ -11,8 +11,8 @@ struct AppState { bool encoder_reversed = false; bool refresh_screen = true; bool editing_param = false; - int selected_param = 0; - int selected_sub_param = 0; + byte selected_param = 0; + byte selected_sub_param = 0; byte selected_channel = 0; // 0=tempo, 1-6=output channel byte selected_shuffle = 0; Clock::Source selected_source = Clock::SOURCE_INTERNAL; diff --git a/gravity.cpp b/gravity.cpp index 159dc6f..658a960 100644 --- a/gravity.cpp +++ b/gravity.cpp @@ -13,7 +13,7 @@ // Initialize the static pointer for the EncoderDir class to null. We want to // have a static pointer to decouple the ISR from the global gravity object. -EncoderDir* EncoderDir::_instance = nullptr; +Encoder* Encoder::_instance = nullptr; void Gravity::Init() { initClock(); @@ -74,11 +74,11 @@ void Gravity::Process() { // Pin Change Interrupt on Port D (D4). ISR(PCINT2_vect) { - EncoderDir::isr(); + Encoder::isr(); }; // Pin Change Interrupt on Port C (D17/A3). ISR(PCINT1_vect) { - EncoderDir::isr(); + Encoder::isr(); }; // Global instance diff --git a/gravity.h b/gravity.h index 2002616..1cc424b 100644 --- a/gravity.h +++ b/gravity.h @@ -8,7 +8,7 @@ #include "button.h" #include "clock.h" #include "digital_output.h" -#include "encoder_dir.h" +#include "encoder.h" #include "peripherials.h" // Hardware abstraction wrapper for the Gravity module. @@ -32,7 +32,7 @@ class Gravity { U8G2_SSD1306_128X64_NONAME_1_HW_I2C display; // OLED display object. Clock clock; // Clock source wrapper. DigitalOutput outputs[OUTPUT_COUNT]; // An array containing each Output object. - EncoderDir encoder; // Rotary encoder with button instance + Encoder encoder; // Rotary encoder with button instance Button shift_button; Button play_button; AnalogInput cv1; -- 2.39.5 From 6d0a9f9f7fc61bcb180a407984e2b1ec3a8f2810 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 28 Jun 2025 09:46:50 -0700 Subject: [PATCH 29/69] additional refactoring, small memory reduction. --- examples/Gravity/Gravity.ino | 25 +++++++++++++------------ examples/Gravity/display.h | 12 ++++++------ 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/examples/Gravity/Gravity.ino b/examples/Gravity/Gravity.ino index 8b2234c..7604f94 100644 --- a/examples/Gravity/Gravity.ino +++ b/examples/Gravity/Gravity.ino @@ -142,7 +142,7 @@ void HandleEncoderPressed() { app.refresh_screen = true; } -void HandleRotate(Direction dir, int val) { +void HandleRotate(int val) { if (!app.editing_param) { // Navigation Mode const int max_param = (app.selected_channel == 0) ? PARAM_MAIN_LAST : PARAM_CH_LAST; @@ -158,12 +158,8 @@ void HandleRotate(Direction dir, int 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--; - } +void HandlePressedRotate(int val) { + updateSelection(app.selected_channel, val, Gravity::OUTPUT_COUNT + 1); app.selected_param = 0; stateManager.markDirty(); app.refresh_screen = true; @@ -180,7 +176,7 @@ void editMainParameter(int val) { break; case PARAM_MAIN_SOURCE: { - int source = static_cast(app.selected_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); @@ -214,13 +210,13 @@ void editChannelParameter(int val) { ch.setSwing(ch.getSwing() + val); break; case PARAM_CH_CV_SRC: { - int source = static_cast(ch.getCvSource()); + byte 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()); + byte dest = static_cast(ch.getCvDestination()); updateSelection(dest, val, CV_DEST_LAST); ch.setCvDestination(static_cast(dest)); break; @@ -228,12 +224,17 @@ void editChannelParameter(int val) { } } -void updateSelection(int& param, int change, int maxValue) { +// 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); } // -// Helper functions. +// App Helper functions. // void InitAppState(AppState& app) { diff --git a/examples/Gravity/display.h b/examples/Gravity/display.h index e1211bc..a65289b 100644 --- a/examples/Gravity/display.h +++ b/examples/Gravity/display.h @@ -9,7 +9,7 @@ // UI Display functions for drawing the UI to the OLED display. // -const PROGMEM uint8_t TEXT_FONT[437] U8G2_FONT_SECTION("velvetscreen") = +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" @@ -25,7 +25,7 @@ const PROGMEM uint8_t TEXT_FONT[437] U8G2_FONT_SECTION("velvetscreen") = "\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") = +const uint8_t LARGE_FONT[916] U8G2_FONT_SECTION("stk-l") PROGMEM = "#\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" @@ -58,11 +58,11 @@ const PROGMEM uint8_t LARGE_FONT[916] U8G2_FONT_SECTION("stk-l") = #define play_icon_width 14 #define play_icon_height 14 -static const unsigned char play_icon[] PROGMEM = { +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[] PROGMEM = { +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}; @@ -270,8 +270,8 @@ void DisplayChannelPage() { swingDivisionMark(); break; case PARAM_CH_CV_SRC: { + mainText = F("SRC"); switch (ch.getCvSource()) { - mainText = F("SRC"); case CV_NONE: subText = F("NONE"); break; @@ -285,8 +285,8 @@ void DisplayChannelPage() { break; } case PARAM_CH_CV_DEST: { + mainText = F("DEST"); switch (ch.getCvDestination()) { - mainText = F("DEST"); case CV_DEST_NONE: subText = F("NONE"); break; -- 2.39.5 From ceb01bf03f5e1153889517e1d0bae346ab5339fa Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Mon, 30 Jun 2025 17:23:56 +0000 Subject: [PATCH 30/69] Introduce basic Euclidean Rhythm (#9) Each channel can define a euclidean rhythm by setting a number of steps (up to 16) and a number of hits to evenly distribute within those steps. CV Mod is available, however the cv mod acts as an override instead of a sum mix like the other parameters. Refactor `applyCvMod()` so it is only called if cv mod is active for that channel. Now the setter methods will update the final output value if cv mod is not active. Reviewed-on: https://git.pinkduck.xyz/adam/libGravity/pulls/9 Co-authored-by: Adam Wonak Co-committed-by: Adam Wonak --- examples/Gravity/Gravity.ino | 23 ++++++++- examples/Gravity/app_state.h | 2 + examples/Gravity/channel.h | 86 ++++++++++++++++++++++++++------- examples/Gravity/display.h | 17 ++++++- examples/Gravity/euclidean.h | 82 +++++++++++++++++++++++++++++++ examples/Gravity/save_state.cpp | 4 ++ examples/Gravity/save_state.h | 4 +- 7 files changed, 197 insertions(+), 21 deletions(-) create mode 100644 examples/Gravity/euclidean.h diff --git a/examples/Gravity/Gravity.ino b/examples/Gravity/Gravity.ino index 7604f94..8a46749 100644 --- a/examples/Gravity/Gravity.ino +++ b/examples/Gravity/Gravity.ino @@ -60,8 +60,23 @@ void loop() { // 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); + auto& ch = app.channel[i]; + // Only apply CV to the channel when the current channel has cv + // mod configured. + if (ch.isCvModActive()) { + // hack -- do not apply mod to euclidean rhythm when editing. + bool editing_euc; + editing_euc |= ch.getCvDestination() == CV_DEST_EUC_STEPS; + editing_euc |= ch.getCvDestination() == CV_DEST_EUC_HITS; + editing_euc &= (app.selected_channel - 1) == i; + editing_euc &= app.editing_param; + if (editing_euc) { + continue; + } + ch.applyCvMod(cv1, cv2); + } } // Check for dirty state eligible to be saved. @@ -209,6 +224,12 @@ void editChannelParameter(int val) { 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_CV_SRC: { byte source = static_cast(ch.getCvSource()); updateSelection(source, val, CV_LAST); diff --git a/examples/Gravity/app_state.h b/examples/Gravity/app_state.h index 3f78caf..201ac72 100644 --- a/examples/Gravity/app_state.h +++ b/examples/Gravity/app_state.h @@ -39,6 +39,8 @@ enum ParamsChannelPage { PARAM_CH_DUTY, PARAM_CH_OFFSET, PARAM_CH_SWING, + PARAM_CH_EUC_STEPS, + PARAM_CH_EUC_HITS, PARAM_CH_CV_SRC, PARAM_CH_CV_DEST, PARAM_CH_LAST, diff --git a/examples/Gravity/channel.h b/examples/Gravity/channel.h index 3567643..5578d42 100644 --- a/examples/Gravity/channel.h +++ b/examples/Gravity/channel.h @@ -3,6 +3,7 @@ #include #include +#include "euclidean.h" // Enums for CV configuration enum CvSource { @@ -19,6 +20,8 @@ enum CvDestination { CV_DEST_DUTY, CV_DEST_OFFSET, CV_DEST_SWING, + CV_DEST_EUC_STEPS, + CV_DEST_EUC_HITS, CV_DEST_LAST, }; @@ -41,6 +44,7 @@ class Channel { base_duty_cycle = 50; base_offset = 0; base_swing = 50; + cv_source = CV_NONE; cv_destination = CV_DEST_NONE; @@ -49,17 +53,46 @@ class Channel { cvmod_duty_cycle = base_duty_cycle; cvmod_offset = base_offset; cvmod_swing = base_swing; + + pattern.Init(DEFAULT_PATTERN); } // Setters (Set the BASE value) void setClockMod(int index) { - if (index >= 0 && index < MOD_CHOICE_SIZE) base_clock_mod_index = index; + base_clock_mod_index = constrain(index, 0, MOD_CHOICE_SIZE - 1); + if (!isCvModActive()) { + cvmod_clock_mod_index = base_clock_mod_index; + } } - void setProbability(int prob) { base_probability = constrain(prob, 0, 100); } - void setDutyCycle(int duty) { base_duty_cycle = constrain(duty, 1, 99); } - void setOffset(int off) { base_offset = constrain(off, 0, 99); } - void setSwing(int val) { base_swing = constrain(val, 50, 95); } + + void setProbability(int prob) { + base_probability = constrain(prob, 0, 100); + if (!isCvModActive()) { + cvmod_probability = base_probability; + } + } + + void setDutyCycle(int duty) { + base_duty_cycle = constrain(duty, 1, 99); + if (!isCvModActive()) { + cvmod_duty_cycle = base_duty_cycle; + } + } + + void setOffset(int off) { + base_offset = constrain(off, 0, 99); + if (!isCvModActive()) { + cvmod_offset = base_offset; + } + } + void setSwing(int val) { + base_swing = constrain(val, 50, 95); + if (!isCvModActive()) { + cvmod_swing = base_swing; + } + } + void setCvSource(CvSource source) { cv_source = source; } void setCvDestination(CvDestination dest) { cv_destination = dest; } @@ -75,6 +108,12 @@ class Channel { CvDestination getCvDestination() { return cv_destination; } bool isCvModActive() const { return cv_source != CV_NONE && cv_destination != CV_DEST_NONE; } + // Euclidean + void setSteps(int val) { pattern.SetSteps(val); } + void setHits(int val) { pattern.SetHits(val); } + byte getSteps() { return pattern.GetSteps(); } + byte getHits() { return pattern.GetHits(); } + /** * @brief Processes a clock tick and determines if the output should be high or low. * @param tick The current clock tick count. @@ -95,11 +134,21 @@ class Channel { const uint32_t current_tick_offset = tick + offset_pulses + swing_pulses; - // Step check + // Duty cycle high check logic if (!output.On()) { + // Step check if (current_tick_offset % mod_pulses == 0) { - // Duty cycle high check - if (cvmod_probability >= random(0, 100)) { + bool hit = cvmod_probability >= random(0, 100); + // Euclidean rhythm 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(); } } @@ -113,16 +162,6 @@ class Channel { } void applyCvMod(int cv1_value, int cv2_value) { - if (!isCvModActive()) { - // If CV is off, ensure cv modded values match the base values. - 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; - return; - } - // Use the CV value for current selected cv source. int value = (cv_source == CV_1) ? cv1_value : cv2_value; @@ -153,6 +192,14 @@ class Channel { (cv_destination == CV_DEST_SWING) ? constrain(base_swing + map(value, -512, 512, -25, 25), 50, 95) : base_swing; + + if (cv_destination == CV_DEST_EUC_STEPS) { + pattern.SetSteps(map(value, -512, 512, 0, MAX_PATTERN_LEN)); + } + + if (cv_destination == CV_DEST_EUC_HITS) { + pattern.SetHits(map(value, -512, 512, 0, pattern.GetSteps())); + } } private: @@ -173,6 +220,9 @@ class Channel { // CV configuration CvSource cv_source = CV_NONE; CvDestination cv_destination = CV_DEST_NONE; + + // Euclidean pattern + Pattern pattern; }; #endif // CHANNEL_H \ No newline at end of file diff --git a/examples/Gravity/display.h b/examples/Gravity/display.h index a65289b..c1dc144 100644 --- a/examples/Gravity/display.h +++ b/examples/Gravity/display.h @@ -269,6 +269,14 @@ void DisplayChannelPage() { subText = "DOWN BEAT"; swingDivisionMark(); break; + case PARAM_CH_EUC_STEPS: + mainText = String(ch.getSteps()); + subText = "EUCLID STEPS"; + break; + case PARAM_CH_EUC_HITS: + mainText = String(ch.getHits()); + subText = "EUCLID HITS"; + break; case PARAM_CH_CV_SRC: { mainText = F("SRC"); switch (ch.getCvSource()) { @@ -305,6 +313,12 @@ void DisplayChannelPage() { 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; } @@ -315,7 +329,8 @@ void DisplayChannelPage() { // Draw Channel Page menu items String menu_items[PARAM_CH_LAST] = { - F("MOD"), F("PROBABILITY"), F("DUTY"), F("OFFSET"), F("SWING"), F("CV SOURCE"), F("CV DEST")}; + F("MOD"), F("PROBABILITY"), F("DUTY"), F("OFFSET"), F("SWING"), F("EUCLID STEPS"), + F("EUCLID HITS"), F("CV SOURCE"), F("CV DEST")}; drawMenuItems(menu_items, PARAM_CH_LAST); } diff --git a/examples/Gravity/euclidean.h b/examples/Gravity/euclidean.h new file mode 100644 index 0000000..57e5cb6 --- /dev/null +++ b/examples/Gravity/euclidean.h @@ -0,0 +1,82 @@ +#ifndef EUCLIDEAN_H +#define EUCLIDEAN_H + +#define MAX_PATTERN_LEN 16 + +struct PatternState { + uint8_t steps; + uint8_t hits; +}; + +const PatternState DEFAULT_PATTERN = {1, 1}; + +class Pattern { + public: + Pattern() {} + ~Pattern() {} + + enum Step { + REST, + HIT, + }; + + void Init(PatternState state) { + steps_ = constrain(state.steps, 1, MAX_PATTERN_LEN); + hits_ = constrain(state.hits, 1, steps_); + updatePattern(); + } + + PatternState GetState() { return {steps_, hits_}; } + + Step GetCurrentStep(byte i) { return pattern_[i]; } + + void SetSteps(int steps) { + steps_ = constrain(steps, 1, MAX_PATTERN_LEN); + hits_ = min(hits_, steps_); + updatePattern(); + } + + void SetHits(int hits) { + hits_ = constrain(hits, 1, steps_); + updatePattern(); + } + + void Reset() { step_index_ = 0; } + + uint8_t GetSteps() { return steps_; } + uint8_t GetHits() { return hits_; } + uint8_t GetStepIndex() { return step_index_; } + + // Get the current step value and advance the euclidean rhythm step index + // to the next step in the pattern. + Step NextStep() { + if (steps_ == 0) return REST; + + Step value = GetCurrentStep(step_index_); + step_index_ = (step_index_ < steps_ - 1) ? step_index_ + 1 : 0; + return value; + } + + private: + uint8_t steps_ = 0; + uint8_t hits_ = 0; + volatile uint8_t step_index_ = 0; + Step pattern_[MAX_PATTERN_LEN]; + + // Update the euclidean rhythm pattern when attributes change. + void updatePattern() { + byte bucket = 0; + pattern_[0] = HIT; + for (int i = 1; i < steps_; i++) { + bucket += hits_; + if (bucket >= steps_) { + bucket -= steps_; + pattern_[i] = HIT; + } else { + pattern_[i] = REST; + } + } + } +}; + +#endif \ No newline at end of file diff --git a/examples/Gravity/save_state.cpp b/examples/Gravity/save_state.cpp index f7529ed..aca6977 100644 --- a/examples/Gravity/save_state.cpp +++ b/examples/Gravity/save_state.cpp @@ -30,6 +30,8 @@ bool StateManager::initialize(AppState& app) { ch.setSwing(saved_ch_state.base_shuffle); ch.setCvSource(static_cast(saved_ch_state.cv_source)); ch.setCvDestination(static_cast(saved_ch_state.cv_destination)); + ch.setSteps(saved_ch_state.euc_steps); + ch.setHits(saved_ch_state.euc_hits); } return true; @@ -109,6 +111,8 @@ void StateManager::_saveState(const AppState& app) { save_ch.base_shuffle = ch.getSwing(); save_ch.cv_source = static_cast(ch.getCvSource()); save_ch.cv_destination = static_cast(ch.getCvDestination()); + save_ch.euc_steps = ch.getSteps(); + save_ch.euc_hits = ch.getHits(); } EEPROM.put(sizeof(Metadata), save_data); } diff --git a/examples/Gravity/save_state.h b/examples/Gravity/save_state.h index eb3c7a2..d37a6ae 100644 --- a/examples/Gravity/save_state.h +++ b/examples/Gravity/save_state.h @@ -9,7 +9,7 @@ struct AppState; // Define the constants for the current firmware. const char SKETCH_NAME[] = "Gravity"; -const byte SKETCH_VERSION = 4; +const byte SKETCH_VERSION = 5; // Define the minimum amount of time between EEPROM writes. static const unsigned long SAVE_DELAY_MS = 2000; @@ -44,6 +44,8 @@ class StateManager { byte base_shuffle; byte cv_source; // Cast the CvSource enum to a byte for storage byte cv_destination; // Cast the CvDestination enum as a byte for storage + byte euc_steps; + byte euc_hits; }; // This struct holds all the parameters we want to save. struct EepromData { -- 2.39.5 From edddfd5879952ef8c06f51c82917fa0c5b7a1c00 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Tue, 1 Jul 2025 12:23:50 -0700 Subject: [PATCH 31/69] Optimize euclidean pattern by changing from an array of ints to a bitmask. --- examples/Gravity/euclidean.h | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/examples/Gravity/euclidean.h b/examples/Gravity/euclidean.h index 57e5cb6..f18e0ac 100644 --- a/examples/Gravity/euclidean.h +++ b/examples/Gravity/euclidean.h @@ -1,7 +1,7 @@ #ifndef EUCLIDEAN_H #define EUCLIDEAN_H -#define MAX_PATTERN_LEN 16 +#define MAX_PATTERN_LEN 32 struct PatternState { uint8_t steps; @@ -26,9 +26,12 @@ class Pattern { updatePattern(); } - PatternState GetState() { return {steps_, hits_}; } + PatternState GetState() const { return {steps_, hits_}; } - Step GetCurrentStep(byte i) { return pattern_[i]; } + Step GetCurrentStep(byte i) { + if (i >= MAX_PATTERN_LEN) return REST; + return (pattern_bitmap_ & (1UL << i)) ? HIT : REST; + } void SetSteps(int steps) { steps_ = constrain(steps, 1, MAX_PATTERN_LEN); @@ -47,8 +50,6 @@ class Pattern { uint8_t GetHits() { return hits_; } uint8_t GetStepIndex() { return step_index_; } - // Get the current step value and advance the euclidean rhythm step index - // to the next step in the pattern. Step NextStep() { if (steps_ == 0) return REST; @@ -61,19 +62,23 @@ class Pattern { uint8_t steps_ = 0; uint8_t hits_ = 0; volatile uint8_t step_index_ = 0; - Step pattern_[MAX_PATTERN_LEN]; + uint32_t pattern_bitmap_ = 0; - // Update the euclidean rhythm pattern when attributes change. + // Update the euclidean rhythm pattern using bitmap void updatePattern() { + pattern_bitmap_ = 0; // Clear the bitmap + + if (steps_ == 0) return; + byte bucket = 0; - pattern_[0] = HIT; + // Set the first bit (index 0) if it's a HIT + pattern_bitmap_ |= (1UL << 0); + for (int i = 1; i < steps_; i++) { bucket += hits_; if (bucket >= steps_) { bucket -= steps_; - pattern_[i] = HIT; - } else { - pattern_[i] = REST; + pattern_bitmap_ |= (1UL << i); } } } -- 2.39.5 From dd1228be00bd739300ffb846aa7c8ce3afdb302f Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Wed, 2 Jul 2025 02:45:39 +0000 Subject: [PATCH 32/69] Vendorize uClock (#10) Add copy of uClock to the repo including memory optimization changes. Also add user config setting for changing Pulse Out resolution. Reviewed-on: https://git.pinkduck.xyz/adam/libGravity/pulls/10 Co-authored-by: Adam Wonak Co-committed-by: Adam Wonak --- README.md | 5 +- clock.h | 17 +- examples/Gravity/Gravity.ino | 33 +++ examples/Gravity/app_state.h | 2 + examples/Gravity/channel.h | 25 +- examples/Gravity/display.h | 41 +++- examples/Gravity/save_state.cpp | 5 +- examples/Gravity/save_state.h | 1 + gravity.cpp | 4 +- gravity.h | 3 +- uClock.cpp | 409 ++++++++++++++++++++++++++++++++ uClock.h | 180 ++++++++++++++ uClock/platforms/avr.h | 98 ++++++++ uClock/uClock.h | 180 ++++++++++++++ 14 files changed, 966 insertions(+), 37 deletions(-) create mode 100755 uClock.cpp create mode 100755 uClock.h create mode 100644 uClock/platforms/avr.h create mode 100755 uClock/uClock.h diff --git a/README.md b/README.md index 58d0e58..f244487 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,9 @@ Common directory locations: ## Required Third-party Libraries -* [uClock](https://github.com/midilab/uClock) [MIT] - Handle clock tempo, external clock input, and internal clock timer handler. +* [uClock](https://github.com/midilab/uClock) [MIT] - (Included with this repo) Handle clock tempo, external clock input, and internal clock timer handler. * [RotateEncoder](https://github.com/mathertel/RotaryEncoder) [BSD] - Library for reading and interpreting encoder rotation. -* [Adafruit_GFX](https://github.com/adafruit/Adafruit-GFX-Library) [BSD] - Graphics helper library. -* [Adafruit_SSD1306](https://github.com/adafruit/Adafruit_SSD1306) [BSD] - Library for interacting with the SSD1306 OLED display. +* [U8g2](https://github.com/olikraus/u8g2/) [MIT] - Graphics helper library. ## Example diff --git a/clock.h b/clock.h index 19ed250..00bd213 100644 --- a/clock.h +++ b/clock.h @@ -13,9 +13,9 @@ #define CLOCK_H #include -#include #include "peripherials.h" +#include "uClock.h" // MIDI clock, start, stop, and continue byte definitions - based on MIDI 1.0 Standards. #define MIDI_CLOCK 0xF8 @@ -39,6 +39,14 @@ class Clock { SOURCE_LAST, }; + enum Pulse { + PULSE_NONE, + PULSE_PPQN_1, + PULSE_PPQN_4, + PULSE_PPQN_24, + PULSE_LAST, + }; + void Init() { NeoSerial.begin(31250); @@ -55,7 +63,6 @@ class Clock { uClock.setOnClockStart(sendMIDIStart); uClock.setOnClockStop(sendMIDIStop); uClock.setOnSync24(sendMIDIClock); - uClock.setOnSync48(sendPulseOut); uClock.start(); } @@ -75,7 +82,7 @@ class Clock { void SetSource(Source source) { bool was_playing = !IsPaused(); uClock.stop(); - // If source is currently MIDI, disable the serial interrupt handler. + // If we are changing the source from MIDI, disable the serial interrupt handler. if (source_ == SOURCE_EXTERNAL_MIDI) { NeoSerial.attachInterrupt(serialEventNoop); } @@ -175,10 +182,6 @@ class Clock { static void sendMIDIClock(uint32_t tick) { NeoSerial.write(MIDI_CLOCK); } - - static void sendPulseOut(uint32_t tick) { - digitalWrite(PULSE_OUT_PIN, !digitalRead(PULSE_OUT_PIN)); - } }; #endif \ No newline at end of file diff --git a/examples/Gravity/Gravity.ino b/examples/Gravity/Gravity.ino index 8a46749..99c33a5 100644 --- a/examples/Gravity/Gravity.ino +++ b/examples/Gravity/Gravity.ino @@ -101,6 +101,32 @@ void HandleIntClockTick(uint32_t tick) { } } + // Pulse Out gate + if (app.selected_pulse != Clock::PULSE_NONE) { + int clock_index; + switch (app.selected_pulse) { + case Clock::PULSE_PPQN_24: + clock_index = 0; + break; + case Clock::PULSE_PPQN_4: + clock_index = 4; + break; + case Clock::PULSE_PPQN_1: + clock_index = 7; + break; + } + + const uint16_t pulse_high_ticks = clock_mod_pulses[clock_index]; + const uint32_t pulse_low_ticks = tick + max((long)(pulse_high_ticks / 2), 1L); + + if (tick % pulse_high_ticks == 0) { + gravity.pulse.High(); + } + if (pulse_low_ticks % pulse_high_ticks == 0) { + gravity.pulse.Low(); + } + } + if (!app.editing_param) { app.refresh_screen |= refresh; } @@ -197,6 +223,13 @@ void editMainParameter(int val) { 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(); + } case PARAM_MAIN_ENCODER_DIR: updateSelection(app.selected_sub_param, val, 2); break; diff --git a/examples/Gravity/app_state.h b/examples/Gravity/app_state.h index 201ac72..062ac3c 100644 --- a/examples/Gravity/app_state.h +++ b/examples/Gravity/app_state.h @@ -16,6 +16,7 @@ struct AppState { byte selected_channel = 0; // 0=tempo, 1-6=output channel byte selected_shuffle = 0; Clock::Source selected_source = Clock::SOURCE_INTERNAL; + Clock::Pulse selected_pulse = Clock::PULSE_PPQN_24; Channel channel[Gravity::OUTPUT_COUNT]; }; @@ -28,6 +29,7 @@ static Channel& GetSelectedChannel() { enum ParamsMainPage { PARAM_MAIN_TEMPO, PARAM_MAIN_SOURCE, + PARAM_MAIN_PULSE, PARAM_MAIN_ENCODER_DIR, PARAM_MAIN_RESET_STATE, PARAM_MAIN_LAST, diff --git a/examples/Gravity/channel.h b/examples/Gravity/channel.h index 5578d42..9acbb70 100644 --- a/examples/Gravity/channel.h +++ b/examples/Gravity/channel.h @@ -3,6 +3,7 @@ #include #include + #include "euclidean.h" // Enums for CV configuration @@ -66,7 +67,7 @@ class Channel { } } - void setProbability(int prob) { + void setProbability(int prob) { base_probability = constrain(prob, 0, 100); if (!isCvModActive()) { cvmod_probability = base_probability; @@ -74,20 +75,20 @@ class Channel { } void setDutyCycle(int duty) { - base_duty_cycle = constrain(duty, 1, 99); + base_duty_cycle = constrain(duty, 1, 99); if (!isCvModActive()) { cvmod_duty_cycle = base_duty_cycle; } } - void setOffset(int off) { + void setOffset(int off) { base_offset = constrain(off, 0, 99); if (!isCvModActive()) { cvmod_offset = base_offset; } } void setSwing(int val) { - base_swing = constrain(val, 50, 95); + base_swing = constrain(val, 50, 95); if (!isCvModActive()) { cvmod_swing = base_swing; } @@ -141,12 +142,12 @@ class Channel { bool hit = cvmod_probability >= random(0, 100); // Euclidean rhythm 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; + 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(); @@ -192,11 +193,11 @@ class Channel { (cv_destination == CV_DEST_SWING) ? constrain(base_swing + map(value, -512, 512, -25, 25), 50, 95) : base_swing; - + if (cv_destination == CV_DEST_EUC_STEPS) { pattern.SetSteps(map(value, -512, 512, 0, MAX_PATTERN_LEN)); } - + if (cv_destination == CV_DEST_EUC_HITS) { pattern.SetHits(map(value, -512, 512, 0, pattern.GetSteps())); } diff --git a/examples/Gravity/display.h b/examples/Gravity/display.h index c1dc144..038d4bf 100644 --- a/examples/Gravity/display.h +++ b/examples/Gravity/display.h @@ -68,16 +68,16 @@ static const unsigned char pause_icon[28] PROGMEM = { 0x38, 0x0E, 0x00, 0x00}; // 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; +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; // Helper function to draw centered text void drawCenteredText(const char* text, int y, const uint8_t* font) { @@ -204,6 +204,23 @@ void DisplayMainPage() { 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"); @@ -218,7 +235,7 @@ void DisplayMainPage() { 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("ENCODER DIR"), F("RESET")}; + String menu_items[PARAM_MAIN_LAST] = {F("TEMPO"), F("SOURCE"), F("PULSE OUT"), F("ENCODER DIR"), F("RESET")}; drawMenuItems(menu_items, PARAM_MAIN_LAST); } @@ -329,7 +346,7 @@ void DisplayChannelPage() { // 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("MOD"), F("PROBABILITY"), F("DUTY"), F("OFFSET"), F("SWING"), F("EUCLID STEPS"), F("EUCLID HITS"), F("CV SOURCE"), F("CV DEST")}; drawMenuItems(menu_items, PARAM_CH_LAST); } diff --git a/examples/Gravity/save_state.cpp b/examples/Gravity/save_state.cpp index aca6977..c435df8 100644 --- a/examples/Gravity/save_state.cpp +++ b/examples/Gravity/save_state.cpp @@ -17,6 +17,7 @@ bool StateManager::initialize(AppState& app) { 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); // Loop through and restore each channel's state. for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { @@ -54,6 +55,7 @@ void StateManager::reset(AppState& app) { app.selected_param = 0; app.selected_channel = 0; app.selected_source = Clock::SOURCE_INTERNAL; + app.selected_pulse = Clock::PULSE_PPQN_24; for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { app.channel[i].Init(); @@ -61,7 +63,7 @@ void StateManager::reset(AppState& app) { noInterrupts(); _saveMetadata(); // Write the new metadata - _saveState(app); // Write the new (default) app state + _saveState(app); // Write the new (default) app state interrupts(); _isDirty = false; @@ -97,6 +99,7 @@ void StateManager::_saveState(const AppState& app) { 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); // Loop through and populate each channel's state for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { diff --git a/examples/Gravity/save_state.h b/examples/Gravity/save_state.h index d37a6ae..8a2659e 100644 --- a/examples/Gravity/save_state.h +++ b/examples/Gravity/save_state.h @@ -54,6 +54,7 @@ class StateManager { byte selected_param; byte selected_channel; byte selected_source; + byte selected_pulse; ChannelState channel_data[Gravity::OUTPUT_COUNT]; }; diff --git a/gravity.cpp b/gravity.cpp index 658a960..3ef6d2b 100644 --- a/gravity.cpp +++ b/gravity.cpp @@ -12,7 +12,7 @@ #include "gravity.h" // Initialize the static pointer for the EncoderDir class to null. We want to -// have a static pointer to decouple the ISR from the global gravity object. +// have a static pointer to decouple the ISR from the global gravity object. Encoder* Encoder::_instance = nullptr; void Gravity::Init() { @@ -52,6 +52,8 @@ void Gravity::initOutputs() { outputs[3].Init(OUT_CH4); outputs[4].Init(OUT_CH5); outputs[5].Init(OUT_CH6); + // Expansion Pulse Output + pulse.Init(PULSE_OUT_PIN); } void Gravity::initDisplay() { // OLED Display configuration. diff --git a/gravity.h b/gravity.h index 1cc424b..5701586 100644 --- a/gravity.h +++ b/gravity.h @@ -32,7 +32,8 @@ class Gravity { U8G2_SSD1306_128X64_NONAME_1_HW_I2C display; // OLED display object. Clock clock; // Clock source wrapper. DigitalOutput outputs[OUTPUT_COUNT]; // An array containing each Output object. - Encoder encoder; // Rotary encoder with button instance + DigitalOutput pulse; // MIDI Expander module pulse output. + Encoder encoder; // Rotary encoder with button instance Button shift_button; Button play_button; AnalogInput cv1; diff --git a/uClock.cpp b/uClock.cpp new file mode 100755 index 0000000..1f53084 --- /dev/null +++ b/uClock.cpp @@ -0,0 +1,409 @@ +/*! + * @file uClock.cpp + * Project BPM clock generator for Arduino + * @brief A Library to implement BPM clock tick calls using hardware interruption. Supported and tested on AVR boards(ATmega168/328, ATmega16u4/32u4 and ATmega2560) and ARM boards(RPI2040, Teensy, Seedstudio XIAO M0 and ESP32) + * @version 2.2.1 + * @author Romulo Silva + * @date 10/06/2017 + * @license MIT - (c) 2024 - Romulo Silva - contact@midilab.co + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ +#include "uClock.h" +#include "uClock/platforms/avr.h" + +// +// Platform specific timer setup/control +// +// initTimer(uint32_t us_interval) and setTimer(uint32_t us_interval) +// are called from architecture specific module included at the +// header of this file +void uclockInitTimer() +{ + // begin at 120bpm + initTimer(uClock.bpmToMicroSeconds(120.00)); +} + +void setTimerTempo(float bpm) +{ + setTimer(uClock.bpmToMicroSeconds(bpm)); +} + +namespace umodular { namespace clock { + +static inline uint32_t phase_mult(uint32_t val) +{ + return (val * PHASE_FACTOR) >> 8; +} + +static inline uint32_t clock_diff(uint32_t old_clock, uint32_t new_clock) +{ + if (new_clock >= old_clock) { + return new_clock - old_clock; + } else { + return new_clock + (4294967295 - old_clock); + } +} + +uClockClass::uClockClass() +{ + tempo = 120; + start_timer = 0; + last_interval = 0; + sync_interval = 0; + clock_state = PAUSED; + clock_mode = INTERNAL_CLOCK; + resetCounters(); + + onOutputPPQNCallback = nullptr; + onSync24Callback = nullptr; + onClockStartCallback = nullptr; + onClockStopCallback = nullptr; + // initialize reference data + calculateReferencedata(); +} + +void uClockClass::init() +{ + if (ext_interval_buffer == nullptr) + setExtIntervalBuffer(1); + + uclockInitTimer(); + // first interval calculus + setTempo(tempo); +} + +uint32_t uClockClass::bpmToMicroSeconds(float bpm) +{ + return (60000000.0f / (float)output_ppqn / bpm); +} + +void uClockClass::calculateReferencedata() +{ + mod_clock_ref = output_ppqn / input_ppqn; + mod_sync24_ref = output_ppqn / PPQN_24; +} + +void uClockClass::setOutputPPQN(PPQNResolution resolution) +{ + // dont allow PPQN lower than PPQN_4 for output clock (to avoid problems with mod_step_ref) + if (resolution < PPQN_4) + return; + + ATOMIC( + output_ppqn = resolution; + calculateReferencedata(); + ) +} + +void uClockClass::setInputPPQN(PPQNResolution resolution) +{ + ATOMIC( + input_ppqn = resolution; + calculateReferencedata(); + ) +} + +void uClockClass::start() +{ + resetCounters(); + start_timer = millis(); + + if (onClockStartCallback) { + onClockStartCallback(); + } + + if (clock_mode == INTERNAL_CLOCK) { + clock_state = STARTED; + } else { + clock_state = STARTING; + } +} + +void uClockClass::stop() +{ + clock_state = PAUSED; + start_timer = 0; + resetCounters(); + if (onClockStopCallback) { + onClockStopCallback(); + } +} + +void uClockClass::pause() +{ + if (clock_mode == INTERNAL_CLOCK) { + if (clock_state == PAUSED) { + start(); + } else { + stop(); + } + } +} + +void uClockClass::setTempo(float bpm) +{ + if (clock_mode == EXTERNAL_CLOCK) { + return; + } + + if (bpm < MIN_BPM || bpm > MAX_BPM) { + return; + } + + ATOMIC( + tempo = bpm + ) + + setTimerTempo(bpm); +} + +float uClockClass::getTempo() +{ + if (clock_mode == EXTERNAL_CLOCK) { + uint32_t acc = 0; + // wait the buffer to get full + if (ext_interval_buffer[ext_interval_buffer_size-1] == 0) { + return tempo; + } + for (uint8_t i=0; i < ext_interval_buffer_size; i++) { + acc += ext_interval_buffer[i]; + } + if (acc != 0) { + return constrainBpm(freqToBpm(acc / ext_interval_buffer_size)); + } + } + return tempo; +} + +// for software timer implementation(fallback for no board support) +void uClockClass::run() {} + +float inline uClockClass::freqToBpm(uint32_t freq) +{ + return 60000000.0f / (float)(freq * input_ppqn); +} + +float inline uClockClass::constrainBpm(float bpm) +{ + return (bpm < MIN_BPM) ? MIN_BPM : ( bpm > MAX_BPM ? MAX_BPM : bpm ); +} + +void uClockClass::setClockMode(ClockMode tempo_mode) +{ + clock_mode = tempo_mode; +} + +uClockClass::ClockMode uClockClass::getClockMode() +{ + return clock_mode; +} + +void uClockClass::clockMe() +{ + if (clock_mode == EXTERNAL_CLOCK) { + ATOMIC( + handleExternalClock() + ) + } +} + +void uClockClass::setExtIntervalBuffer(uint8_t buffer_size) +{ + if (ext_interval_buffer != nullptr) + return; + + // alloc once and forever policy + ext_interval_buffer_size = buffer_size; + ext_interval_buffer = (uint32_t*) malloc( sizeof(uint32_t) * ext_interval_buffer_size ); +} + +void uClockClass::resetCounters() +{ + tick = 0; + int_clock_tick = 0; + mod_clock_counter = 0; + + mod_sync24_counter = 0; + sync24_tick = 0; + + ext_clock_tick = 0; + ext_clock_us = 0; + ext_interval_idx = 0; + + for (uint8_t i=0; i < ext_interval_buffer_size; i++) { + ext_interval_buffer[i] = 0; + } +} + +void uClockClass::handleExternalClock() +{ + switch (clock_state) { + case PAUSED: + break; + + case STARTING: + clock_state = STARTED; + ext_clock_us = micros(); + break; + + case STARTED: + uint32_t now_clock_us = micros(); + last_interval = clock_diff(ext_clock_us, now_clock_us); + ext_clock_us = now_clock_us; + + // external clock tick me! + ext_clock_tick++; + + // accumulate interval incomming ticks data for getTempo() smooth reads on slave clock_mode + if(++ext_interval_idx >= ext_interval_buffer_size) { + ext_interval_idx = 0; + } + ext_interval_buffer[ext_interval_idx] = last_interval; + + if (ext_clock_tick == 1) { + ext_interval = last_interval; + } else { + ext_interval = (((uint32_t)ext_interval * (uint32_t)PLL_X) + (uint32_t)(256 - PLL_X) * (uint32_t)last_interval) >> 8; + } + break; + } +} + +void uClockClass::handleTimerInt() +{ + // track main input clock counter + if (mod_clock_counter == mod_clock_ref) + mod_clock_counter = 0; + + // process sync signals first please... + if (mod_clock_counter == 0) { + + if (clock_mode == EXTERNAL_CLOCK) { + // sync tick position with external tick clock + if ((int_clock_tick < ext_clock_tick) || (int_clock_tick > (ext_clock_tick + 1))) { + int_clock_tick = ext_clock_tick; + tick = int_clock_tick * mod_clock_ref; + mod_clock_counter = tick % mod_clock_ref; + } + + uint32_t counter = ext_interval; + uint32_t now_clock_us = micros(); + sync_interval = clock_diff(ext_clock_us, now_clock_us); + + if (int_clock_tick <= ext_clock_tick) { + counter -= phase_mult(sync_interval); + } else { + if (counter > sync_interval) { + counter += phase_mult(counter - sync_interval); + } + } + + // update internal clock timer frequency + float bpm = constrainBpm(freqToBpm(counter)); + if (bpm != tempo) { + tempo = bpm; + setTimerTempo(bpm); + } + } + + // internal clock tick me! + ++int_clock_tick; + } + ++mod_clock_counter; + + // Sync24 callback + if (onSync24Callback) { + if (mod_sync24_counter == mod_sync24_ref) + mod_sync24_counter = 0; + if (mod_sync24_counter == 0) { + onSync24Callback(sync24_tick); + ++sync24_tick; + } + ++mod_sync24_counter; + } + + // main PPQNCallback + if (onOutputPPQNCallback) { + onOutputPPQNCallback(tick); + ++tick; + } +} + +// elapsed time support +uint8_t uClockClass::getNumberOfSeconds(uint32_t time) +{ + if ( time == 0 ) { + return time; + } + return ((_millis - time) / 1000) % SECS_PER_MIN; +} + +uint8_t uClockClass::getNumberOfMinutes(uint32_t time) +{ + if ( time == 0 ) { + return time; + } + return (((_millis - time) / 1000) / SECS_PER_MIN) % SECS_PER_MIN; +} + +uint8_t uClockClass::getNumberOfHours(uint32_t time) +{ + if ( time == 0 ) { + return time; + } + return (((_millis - time) / 1000) % SECS_PER_DAY) / SECS_PER_HOUR; +} + +uint8_t uClockClass::getNumberOfDays(uint32_t time) +{ + if ( time == 0 ) { + return time; + } + return ((_millis - time) / 1000) / SECS_PER_DAY; +} + +uint32_t uClockClass::getNowTimer() +{ + return _millis; +} + +uint32_t uClockClass::getPlayTime() +{ + return start_timer; +} + +} } // end namespace umodular::clock + +umodular::clock::uClockClass uClock; + +volatile uint32_t _millis = 0; + +// +// TIMER HANDLER +// +void uClockHandler() +{ + // global timer counter + _millis = millis(); + + if (uClock.clock_state == uClock.STARTED) { + uClock.handleTimerInt(); + } +} diff --git a/uClock.h b/uClock.h new file mode 100755 index 0000000..d8670b0 --- /dev/null +++ b/uClock.h @@ -0,0 +1,180 @@ +/*! + * @file uClock.h + * Project BPM clock generator for Arduino + * @brief A Library to implement BPM clock tick calls using hardware interruption. Supported and tested on AVR boards(ATmega168/328, ATmega16u4/32u4 and ATmega2560) and ARM boards(RPI2040, Teensy, Seedstudio XIAO M0 and ESP32) + * @version 2.2.1 + * @author Romulo Silva + * @date 10/06/2017 + * @license MIT - (c) 2024 - Romulo Silva - contact@midilab.co + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +#ifndef __U_CLOCK_H__ +#define __U_CLOCK_H__ + +#include +#include + +namespace umodular { namespace clock { + +#define MIN_BPM 1 +#define MAX_BPM 400 + +#define PHASE_FACTOR 16 +#define PLL_X 220 + +#define SECS_PER_MIN (60UL) +#define SECS_PER_HOUR (3600UL) +#define SECS_PER_DAY (SECS_PER_HOUR * 24L) + +class uClockClass { + + public: + enum ClockMode { + INTERNAL_CLOCK = 0, + EXTERNAL_CLOCK + }; + + enum ClockState { + PAUSED = 0, + STARTING, + STARTED + }; + + enum PPQNResolution { + PPQN_1 = 1, + PPQN_2 = 2, + PPQN_4 = 4, + PPQN_8 = 8, + PPQN_12 = 12, + PPQN_24 = 24, + PPQN_48 = 48, + PPQN_96 = 96, + PPQN_384 = 384, + PPQN_480 = 480, + PPQN_960 = 960 + }; + + ClockState clock_state; + + uClockClass(); + + void setOnOutputPPQN(void (*callback)(uint32_t tick)) { + onOutputPPQNCallback = callback; + } + + void setOnSync24(void (*callback)(uint32_t tick)) { + onSync24Callback = callback; + } + + void setOnClockStart(void (*callback)()) { + onClockStartCallback = callback; + } + + void setOnClockStop(void (*callback)()) { + onClockStopCallback = callback; + } + + void init(); + void setOutputPPQN(PPQNResolution resolution); + void setInputPPQN(PPQNResolution resolution); + + void handleTimerInt(); + void handleExternalClock(); + void resetCounters(); + + // external class control + void start(); + void stop(); + void pause(); + void setTempo(float bpm); + float getTempo(); + + // for software timer implementation(fallback for no board support) + void run(); + + // external timming control + void setClockMode(ClockMode tempo_mode); + ClockMode getClockMode(); + void clockMe(); + // for smooth slave tempo calculate display you should raise the + // buffer_size of ext_interval_buffer in between 64 to 128. 254 max size. + // note: this doesn't impact on sync time, only display time getTempo() + // if you dont want to use it, it is default set it to 1 for memory save + void setExtIntervalBuffer(uint8_t buffer_size); + + // elapsed time support + uint8_t getNumberOfSeconds(uint32_t time); + uint8_t getNumberOfMinutes(uint32_t time); + uint8_t getNumberOfHours(uint32_t time); + uint8_t getNumberOfDays(uint32_t time); + uint32_t getNowTimer(); + uint32_t getPlayTime(); + + uint32_t bpmToMicroSeconds(float bpm); + + private: + float inline freqToBpm(uint32_t freq); + float inline constrainBpm(float bpm); + void calculateReferencedata(); + + void (*onOutputPPQNCallback)(uint32_t tick); + void (*onSync24Callback)(uint32_t tick); + void (*onClockStartCallback)(); + void (*onClockStopCallback)(); + + // clock input/output control + PPQNResolution output_ppqn = PPQN_96; + PPQNResolution input_ppqn = PPQN_24; + // output and internal counters, ticks and references + uint32_t tick; + uint32_t int_clock_tick; + uint8_t mod_clock_counter; + uint16_t mod_clock_ref; + + uint8_t mod_sync24_counter; + uint16_t mod_sync24_ref; + uint32_t sync24_tick; + + // external clock control + volatile uint32_t ext_clock_us; + volatile uint32_t ext_clock_tick; + volatile uint32_t ext_interval; + uint32_t last_interval; + uint32_t sync_interval; + + float tempo; + uint32_t start_timer; + ClockMode clock_mode; + + volatile uint32_t * ext_interval_buffer = nullptr; + uint8_t ext_interval_buffer_size; + uint16_t ext_interval_idx; +}; + +} } // end namespace umodular::clock + +extern umodular::clock::uClockClass uClock; + +extern "C" { + extern volatile uint32_t _millis; +} + +#endif /* __U_CLOCK_H__ */ diff --git a/uClock/platforms/avr.h b/uClock/platforms/avr.h new file mode 100644 index 0000000..c6a25d7 --- /dev/null +++ b/uClock/platforms/avr.h @@ -0,0 +1,98 @@ +/*! + * @file avr.h + * Project BPM clock generator for Arduino + * @brief A Library to implement BPM clock tick calls using hardware interruption. Supported and tested on AVR boards(ATmega168/328, ATmega16u4/32u4 and ATmega2560) and ARM boards(RPI2040, Teensy, Seedstudio XIAO M0 and ESP32) + * @version 2.2.1 + * @author Romulo Silva + * @date 10/06/2017 + * @license MIT - (c) 2024 - Romulo Silva - contact@midilab.co + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ +#include + +#define ATOMIC(X) noInterrupts(); X; interrupts(); + +// want a different avr clock support? +// TODO: we should do this using macro guards for avrs different clocks freqeuncy setup at compile time +#define AVR_CLOCK_FREQ 16000000 + +// forward declaration of uClockHandler +void uClockHandler(); + +// AVR ISR Entrypoint +ISR(TIMER1_COMPA_vect) +{ + uClockHandler(); +} + +void initTimer(uint32_t init_clock) +{ + ATOMIC( + // 16bits Timer1 init + // begin at 120bpm (48.0007680122882 Hz) + TCCR1A = 0; // set entire TCCR1A register to 0 + TCCR1B = 0; // same for TCCR1B + TCNT1 = 0; // initialize counter value to 0 + // set compare match register for 48.0007680122882 Hz increments + OCR1A = 41665; // = 16000000 / (8 * 48.0007680122882) - 1 (must be <65536) + // turn on CTC mode + TCCR1B |= (1 << WGM12); + // Set CS12, CS11 and CS10 bits for 8 prescaler + TCCR1B |= (0 << CS12) | (1 << CS11) | (0 << CS10); + // enable timer compare interrupt + TIMSK1 |= (1 << OCIE1A); + ) +} + +void setTimer(uint32_t us_interval) +{ + float tick_hertz_interval = 1/((float)us_interval/1000000); + + uint32_t ocr; + uint8_t tccr = 0; + + // 16bits avr timer setup + if ((ocr = AVR_CLOCK_FREQ / ( tick_hertz_interval * 1 )) < 65535) { + // Set CS12, CS11 and CS10 bits for 1 prescaler + tccr |= (0 << CS12) | (0 << CS11) | (1 << CS10); + } else if ((ocr = AVR_CLOCK_FREQ / ( tick_hertz_interval * 8 )) < 65535) { + // Set CS12, CS11 and CS10 bits for 8 prescaler + tccr |= (0 << CS12) | (1 << CS11) | (0 << CS10); + } else if ((ocr = AVR_CLOCK_FREQ / ( tick_hertz_interval * 64 )) < 65535) { + // Set CS12, CS11 and CS10 bits for 64 prescaler + tccr |= (0 << CS12) | (1 << CS11) | (1 << CS10); + } else if ((ocr = AVR_CLOCK_FREQ / ( tick_hertz_interval * 256 )) < 65535) { + // Set CS12, CS11 and CS10 bits for 256 prescaler + tccr |= (1 << CS12) | (0 << CS11) | (0 << CS10); + } else if ((ocr = AVR_CLOCK_FREQ / ( tick_hertz_interval * 1024 )) < 65535) { + // Set CS12, CS11 and CS10 bits for 1024 prescaler + tccr |= (1 << CS12) | (0 << CS11) | (1 << CS10); + } else { + // tempo not achiavable + return; + } + + ATOMIC( + TCCR1B = 0; + OCR1A = ocr-1; + TCCR1B |= (1 << WGM12); + TCCR1B |= tccr; + ) +} \ No newline at end of file diff --git a/uClock/uClock.h b/uClock/uClock.h new file mode 100755 index 0000000..d8670b0 --- /dev/null +++ b/uClock/uClock.h @@ -0,0 +1,180 @@ +/*! + * @file uClock.h + * Project BPM clock generator for Arduino + * @brief A Library to implement BPM clock tick calls using hardware interruption. Supported and tested on AVR boards(ATmega168/328, ATmega16u4/32u4 and ATmega2560) and ARM boards(RPI2040, Teensy, Seedstudio XIAO M0 and ESP32) + * @version 2.2.1 + * @author Romulo Silva + * @date 10/06/2017 + * @license MIT - (c) 2024 - Romulo Silva - contact@midilab.co + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +#ifndef __U_CLOCK_H__ +#define __U_CLOCK_H__ + +#include +#include + +namespace umodular { namespace clock { + +#define MIN_BPM 1 +#define MAX_BPM 400 + +#define PHASE_FACTOR 16 +#define PLL_X 220 + +#define SECS_PER_MIN (60UL) +#define SECS_PER_HOUR (3600UL) +#define SECS_PER_DAY (SECS_PER_HOUR * 24L) + +class uClockClass { + + public: + enum ClockMode { + INTERNAL_CLOCK = 0, + EXTERNAL_CLOCK + }; + + enum ClockState { + PAUSED = 0, + STARTING, + STARTED + }; + + enum PPQNResolution { + PPQN_1 = 1, + PPQN_2 = 2, + PPQN_4 = 4, + PPQN_8 = 8, + PPQN_12 = 12, + PPQN_24 = 24, + PPQN_48 = 48, + PPQN_96 = 96, + PPQN_384 = 384, + PPQN_480 = 480, + PPQN_960 = 960 + }; + + ClockState clock_state; + + uClockClass(); + + void setOnOutputPPQN(void (*callback)(uint32_t tick)) { + onOutputPPQNCallback = callback; + } + + void setOnSync24(void (*callback)(uint32_t tick)) { + onSync24Callback = callback; + } + + void setOnClockStart(void (*callback)()) { + onClockStartCallback = callback; + } + + void setOnClockStop(void (*callback)()) { + onClockStopCallback = callback; + } + + void init(); + void setOutputPPQN(PPQNResolution resolution); + void setInputPPQN(PPQNResolution resolution); + + void handleTimerInt(); + void handleExternalClock(); + void resetCounters(); + + // external class control + void start(); + void stop(); + void pause(); + void setTempo(float bpm); + float getTempo(); + + // for software timer implementation(fallback for no board support) + void run(); + + // external timming control + void setClockMode(ClockMode tempo_mode); + ClockMode getClockMode(); + void clockMe(); + // for smooth slave tempo calculate display you should raise the + // buffer_size of ext_interval_buffer in between 64 to 128. 254 max size. + // note: this doesn't impact on sync time, only display time getTempo() + // if you dont want to use it, it is default set it to 1 for memory save + void setExtIntervalBuffer(uint8_t buffer_size); + + // elapsed time support + uint8_t getNumberOfSeconds(uint32_t time); + uint8_t getNumberOfMinutes(uint32_t time); + uint8_t getNumberOfHours(uint32_t time); + uint8_t getNumberOfDays(uint32_t time); + uint32_t getNowTimer(); + uint32_t getPlayTime(); + + uint32_t bpmToMicroSeconds(float bpm); + + private: + float inline freqToBpm(uint32_t freq); + float inline constrainBpm(float bpm); + void calculateReferencedata(); + + void (*onOutputPPQNCallback)(uint32_t tick); + void (*onSync24Callback)(uint32_t tick); + void (*onClockStartCallback)(); + void (*onClockStopCallback)(); + + // clock input/output control + PPQNResolution output_ppqn = PPQN_96; + PPQNResolution input_ppqn = PPQN_24; + // output and internal counters, ticks and references + uint32_t tick; + uint32_t int_clock_tick; + uint8_t mod_clock_counter; + uint16_t mod_clock_ref; + + uint8_t mod_sync24_counter; + uint16_t mod_sync24_ref; + uint32_t sync24_tick; + + // external clock control + volatile uint32_t ext_clock_us; + volatile uint32_t ext_clock_tick; + volatile uint32_t ext_interval; + uint32_t last_interval; + uint32_t sync_interval; + + float tempo; + uint32_t start_timer; + ClockMode clock_mode; + + volatile uint32_t * ext_interval_buffer = nullptr; + uint8_t ext_interval_buffer_size; + uint16_t ext_interval_idx; +}; + +} } // end namespace umodular::clock + +extern umodular::clock::uClockClass uClock; + +extern "C" { + extern volatile uint32_t _millis; +} + +#endif /* __U_CLOCK_H__ */ -- 2.39.5 From a640723be82c7f9256a857347c186d5da6b808b4 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Tue, 1 Jul 2025 21:31:20 -0700 Subject: [PATCH 33/69] minor memory improvements --- examples/Gravity/app_state.h | 4 ++-- examples/Gravity/channel.h | 13 +++++++------ examples/Gravity/euclidean.h | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/examples/Gravity/app_state.h b/examples/Gravity/app_state.h index 062ac3c..75ce86f 100644 --- a/examples/Gravity/app_state.h +++ b/examples/Gravity/app_state.h @@ -26,7 +26,7 @@ static Channel& GetSelectedChannel() { return app.channel[app.selected_channel - 1]; } -enum ParamsMainPage { +enum ParamsMainPage : uint8_t { PARAM_MAIN_TEMPO, PARAM_MAIN_SOURCE, PARAM_MAIN_PULSE, @@ -35,7 +35,7 @@ enum ParamsMainPage { PARAM_MAIN_LAST, }; -enum ParamsChannelPage { +enum ParamsChannelPage : uint8_t { PARAM_CH_MOD, PARAM_CH_PROB, PARAM_CH_DUTY, diff --git a/examples/Gravity/channel.h b/examples/Gravity/channel.h index 9acbb70..4110039 100644 --- a/examples/Gravity/channel.h +++ b/examples/Gravity/channel.h @@ -7,14 +7,14 @@ #include "euclidean.h" // Enums for CV configuration -enum CvSource { +enum CvSource : uint8_t { CV_NONE, CV_1, CV_2, CV_LAST, }; -enum CvDestination { +enum CvDestination : uint8_t { CV_DEST_NONE, CV_DEST_MOD, CV_DEST_PROB, @@ -28,9 +28,9 @@ enum CvDestination { static const int MOD_CHOICE_SIZE = 21; // Negative for multiply, positive for divide. -static const int clock_mod[MOD_CHOICE_SIZE] = {-24, -12, -8, -6, -4, -3, -2, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 24, 32, 64, 128}; +static const int clock_mod[MOD_CHOICE_SIZE] PROGMEM = {-24, -12, -8, -6, -4, -3, -2, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 24, 32, 64, 128}; // 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] = {4, 8, 12, 16, 24, 32, 48, 96, 192, 288, 384, 480, 576, 1152, 672, 768, 1536, 2304, 3072, 6144, 12288}; +static const int clock_mod_pulses[MOD_CHOICE_SIZE] PROGMEM = {4, 8, 12, 16, 24, 32, 48, 96, 192, 288, 384, 480, 576, 1152, 672, 768, 1536, 2304, 3072, 6144, 12288}; class Channel { public: @@ -103,7 +103,7 @@ class Channel { 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 clock_mod[getClockModIndex(withCvMod)]; } + 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; } CvSource getCvSource() { return cv_source; } CvDestination getCvDestination() { return cv_destination; } @@ -117,12 +117,13 @@ class Channel { /** * @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) { // Calculate output duty cycle state using cv modded values to determine pulse counts. - const uint16_t mod_pulses = clock_mod_pulses[cvmod_clock_mod_index]; + const uint16_t mod_pulses = pgm_read_word_near(&clock_mod_pulses[cvmod_clock_mod_index]); const uint16_t duty_pulses = max((long)((mod_pulses * (100L - cvmod_duty_cycle)) / 100L), 1L); const uint16_t offset_pulses = (long)((mod_pulses * (100L - cvmod_offset)) / 100L); diff --git a/examples/Gravity/euclidean.h b/examples/Gravity/euclidean.h index f18e0ac..3eb75dc 100644 --- a/examples/Gravity/euclidean.h +++ b/examples/Gravity/euclidean.h @@ -15,7 +15,7 @@ class Pattern { Pattern() {} ~Pattern() {} - enum Step { + enum Step : uint8_t { REST, HIT, }; -- 2.39.5 From 7ce8bb661d720cc0704cd696c1fcd34aeebd8c55 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Wed, 2 Jul 2025 14:16:15 -0700 Subject: [PATCH 34/69] refactor cv mod to allow both cv mods configurable per channel. Fix euclidean sum mod. update large font. --- examples/Gravity/Gravity.ino | 23 ++---- examples/Gravity/app_state.h | 4 +- examples/Gravity/channel.h | 122 +++++++++++++++++--------------- examples/Gravity/display.h | 70 ++++++++---------- examples/Gravity/euclidean.h | 6 +- examples/Gravity/save_state.cpp | 16 ++--- examples/Gravity/save_state.h | 10 +-- 7 files changed, 118 insertions(+), 133 deletions(-) diff --git a/examples/Gravity/Gravity.ino b/examples/Gravity/Gravity.ino index 99c33a5..58069c0 100644 --- a/examples/Gravity/Gravity.ino +++ b/examples/Gravity/Gravity.ino @@ -66,15 +66,6 @@ void loop() { // Only apply CV to the channel when the current channel has cv // mod configured. if (ch.isCvModActive()) { - // hack -- do not apply mod to euclidean rhythm when editing. - bool editing_euc; - editing_euc |= ch.getCvDestination() == CV_DEST_EUC_STEPS; - editing_euc |= ch.getCvDestination() == CV_DEST_EUC_HITS; - editing_euc &= (app.selected_channel - 1) == i; - editing_euc &= app.editing_param; - if (editing_euc) { - continue; - } ch.applyCvMod(cv1, cv2); } } @@ -263,16 +254,16 @@ void editChannelParameter(int val) { case PARAM_CH_EUC_HITS: ch.setHits(ch.getHits() + val); break; - case PARAM_CH_CV_SRC: { - byte source = static_cast(ch.getCvSource()); - updateSelection(source, val, CV_LAST); - ch.setCvSource(static_cast(source)); + 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_CV_DEST: { - byte dest = static_cast(ch.getCvDestination()); + case PARAM_CH_CV2_DEST: { + byte dest = static_cast(ch.getCv2Dest()); updateSelection(dest, val, CV_DEST_LAST); - ch.setCvDestination(static_cast(dest)); + ch.setCv2Dest(static_cast(dest)); break; } } diff --git a/examples/Gravity/app_state.h b/examples/Gravity/app_state.h index 75ce86f..a52be4b 100644 --- a/examples/Gravity/app_state.h +++ b/examples/Gravity/app_state.h @@ -43,8 +43,8 @@ enum ParamsChannelPage : uint8_t { PARAM_CH_SWING, PARAM_CH_EUC_STEPS, PARAM_CH_EUC_HITS, - PARAM_CH_CV_SRC, - PARAM_CH_CV_DEST, + PARAM_CH_CV1_DEST, + PARAM_CH_CV2_DEST, PARAM_CH_LAST, }; diff --git a/examples/Gravity/channel.h b/examples/Gravity/channel.h index 4110039..d9a0028 100644 --- a/examples/Gravity/channel.h +++ b/examples/Gravity/channel.h @@ -6,14 +6,7 @@ #include "euclidean.h" -// Enums for CV configuration -enum CvSource : uint8_t { - CV_NONE, - CV_1, - CV_2, - CV_LAST, -}; - +// Enums for CV Mod destination enum CvDestination : uint8_t { CV_DEST_NONE, CV_DEST_MOD, @@ -45,9 +38,8 @@ class Channel { base_duty_cycle = 50; base_offset = 0; base_swing = 50; - - cv_source = CV_NONE; - cv_destination = CV_DEST_NONE; + base_euc_steps = 1; + base_euc_hits = 1; cvmod_clock_mod_index = base_clock_mod_index; cvmod_probability = base_probability; @@ -62,40 +54,56 @@ class Channel { void setClockMod(int index) { base_clock_mod_index = constrain(index, 0, MOD_CHOICE_SIZE - 1); - if (!isCvModActive()) { + if (cv1_dest != CV_DEST_MOD && cv2_dest != CV_DEST_MOD) { cvmod_clock_mod_index = base_clock_mod_index; } } void setProbability(int prob) { base_probability = constrain(prob, 0, 100); - if (!isCvModActive()) { + if (cv1_dest != CV_DEST_PROB && cv2_dest != CV_DEST_PROB) { cvmod_probability = base_probability; } } void setDutyCycle(int duty) { base_duty_cycle = constrain(duty, 1, 99); - if (!isCvModActive()) { + if (cv1_dest != CV_DEST_DUTY && cv2_dest != CV_DEST_DUTY) { cvmod_duty_cycle = base_duty_cycle; } } void setOffset(int off) { base_offset = constrain(off, 0, 99); - if (!isCvModActive()) { + if (cv1_dest != CV_DEST_OFFSET && cv2_dest != CV_DEST_OFFSET) { cvmod_offset = base_offset; } } void setSwing(int val) { base_swing = constrain(val, 50, 95); - if (!isCvModActive()) { + if (cv1_dest != CV_DEST_SWING && cv2_dest != CV_DEST_SWING) { cvmod_swing = base_swing; } } - void setCvSource(CvSource source) { cv_source = source; } - void setCvDestination(CvDestination dest) { cv_destination = dest; } + // 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) @@ -105,15 +113,10 @@ class Channel { 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; } - CvSource getCvSource() { return cv_source; } - CvDestination getCvDestination() { return cv_destination; } - bool isCvModActive() const { return cv_source != CV_NONE && cv_destination != CV_DEST_NONE; } + bool isCvModActive() const { return cv1_dest != CV_DEST_NONE || cv2_dest != CV_DEST_NONE; } - // Euclidean - void setSteps(int val) { pattern.SetSteps(val); } - void setHits(int val) { pattern.SetHits(val); } - byte getSteps() { return pattern.GetSteps(); } - byte getHits() { return pattern.GetHits(); } + 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; } /** * @brief Processes a clock tick and determines if the output should be high or low. @@ -163,54 +166,55 @@ class Channel { } } - void applyCvMod(int cv1_value, int cv2_value) { - // Use the CV value for current selected cv source. - int value = (cv_source == CV_1) ? cv1_value : cv2_value; - + void applyCvMod(int cv1_val, int cv2_val) { // Calculate and store cv modded values using bipolar mapping. // Default to base value if not the current CV destination. - cvmod_clock_mod_index = - (cv_destination == CV_DEST_MOD) - ? constrain(base_clock_mod_index + map(value, -512, 512, -10, 10), 0, MOD_CHOICE_SIZE - 1) - : base_clock_mod_index; + // 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. - cvmod_probability = - (cv_destination == CV_DEST_PROB) - ? constrain(base_probability + map(value, -512, 512, -50, 50), 0, 100) - : base_probability; + int dest_mod = calculateMod(CV_DEST_MOD, cv1_val, cv2_val, -10, 10); + cvmod_clock_mod_index = constrain(base_clock_mod_index + dest_mod, 0, 100); - cvmod_duty_cycle = - (cv_destination == CV_DEST_DUTY) - ? constrain(base_duty_cycle + map(value, -512, 512, -50, 50), 1, 99) - : base_duty_cycle; + int prob_mod = calculateMod(CV_DEST_PROB, cv1_val, cv2_val, -50, 50); + cvmod_probability = constrain(base_probability + prob_mod, 0, 100); - cvmod_offset = - (cv_destination == CV_DEST_OFFSET) - ? constrain(base_offset + map(value, -512, 512, -50, 50), 0, 99) - : base_offset; + int duty_mod = calculateMod(CV_DEST_DUTY, cv1_val, cv2_val, -50, 50); + cvmod_duty_cycle = constrain(base_duty_cycle + duty_mod, 1, 99); - cvmod_swing = - (cv_destination == CV_DEST_SWING) - ? constrain(base_swing + map(value, -512, 512, -25, 25), 50, 95) - : base_swing; + int offset_mod = calculateMod(CV_DEST_OFFSET, cv1_val, cv2_val, -50, 50); + cvmod_offset = constrain(base_offset + offset_mod, 0, 99); - if (cv_destination == CV_DEST_EUC_STEPS) { - pattern.SetSteps(map(value, -512, 512, 0, MAX_PATTERN_LEN)); - } + int swing_mod = calculateMod(CV_DEST_SWING, cv1_val, cv2_val, -25, 25); + cvmod_swing = constrain(base_swing + swing_mod, 50, 95); - if (cv_destination == CV_DEST_EUC_HITS) { - pattern.SetHits(map(value, -512, 512, 0, pattern.GetSteps())); - } + int step_mod = calculateMod(CV_DEST_EUC_STEPS, cv1_val, cv2_val, 0, MAX_PATTERN_LEN); + pattern.SetSteps(base_euc_steps + step_mod); + + int hit_mod = calculateMod(CV_DEST_EUC_HITS, cv1_val, cv2_val, 0, MAX_PATTERN_LEN); + pattern.SetHits(base_euc_hits + hit_mod); } 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; + } // 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; @@ -219,9 +223,9 @@ class Channel { byte cvmod_offset; byte cvmod_swing; - // CV configuration - CvSource cv_source = CV_NONE; - CvDestination cv_destination = CV_DEST_NONE; + // CV mod configuration + CvDestination cv1_dest; + CvDestination cv2_dest; // Euclidean pattern Pattern pattern; diff --git a/examples/Gravity/display.h b/examples/Gravity/display.h index 038d4bf..9349dd0 100644 --- a/examples/Gravity/display.h +++ b/examples/Gravity/display.h @@ -9,6 +9,11 @@ // 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" @@ -25,8 +30,13 @@ const uint8_t TEXT_FONT[437] U8G2_FONT_SECTION("velvetscreen") PROGMEM = "\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 uint8_t LARGE_FONT[916] U8G2_FONT_SECTION("stk-l") PROGMEM = - "#\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#&" +/* + * Font: STK-L.bdf 36pt + * https://stncrn.github.io/u8g2-unifont-helper/ + * "%/0123456789ACDEFINORSTUVXx" + */ +const uint8_t LARGE_FONT[715] U8G2_FONT_SECTION("stk-l") = + "\33\0\4\4\4\5\2\1\6\20\30\0\0\27\0\0\0\1\77\0\0\2\256%'\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|" @@ -37,24 +47,18 @@ const uint8_t LARGE_FONT[916] U8G2_FONT_SECTION("stk-l") PROGMEM = "\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" + "\65d\324\210q\366\356\301w\366\273\1C\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\0I" + "\7so\302\37$N#}\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\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"; + "Qc\10\315j\314(\42\303H\214\33\61\356\340\0S\42\216\37\317\261DKH\221\30\67b\334\210\261" + "c)M\226-\331\301c\307\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\0V\26\177\375\302H\373\377\345\210qCH\221\241\212" + "\4\271\223e\207\1X)~\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 CGH\240\61E\312\14\222)\6" + "Y\64\0\0\0\0\4\377\377\0"; #define play_icon_width 14 #define play_icon_height 14 @@ -287,31 +291,17 @@ void DisplayChannelPage() { swingDivisionMark(); break; case PARAM_CH_EUC_STEPS: - mainText = String(ch.getSteps()); + mainText = String(ch.getSteps(withCvMod)); subText = "EUCLID STEPS"; break; case PARAM_CH_EUC_HITS: - mainText = String(ch.getHits()); + mainText = String(ch.getHits(withCvMod)); subText = "EUCLID HITS"; break; - case PARAM_CH_CV_SRC: { - mainText = F("SRC"); - switch (ch.getCvSource()) { - case CV_NONE: - subText = F("NONE"); - break; - case CV_1: - subText = F("CV 1"); - break; - case CV_2: - subText = F("CV 2"); - break; - } - break; - } - case PARAM_CH_CV_DEST: { - mainText = F("DEST"); - switch (ch.getCvDestination()) { + 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; @@ -347,7 +337,7 @@ void DisplayChannelPage() { // 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("CV SOURCE"), F("CV DEST")}; + F("EUCLID HITS"), F("CV1 MOD"), F("CV2 MOD")}; drawMenuItems(menu_items, PARAM_CH_LAST); } diff --git a/examples/Gravity/euclidean.h b/examples/Gravity/euclidean.h index 3eb75dc..9c744c3 100644 --- a/examples/Gravity/euclidean.h +++ b/examples/Gravity/euclidean.h @@ -46,9 +46,9 @@ class Pattern { void Reset() { step_index_ = 0; } - uint8_t GetSteps() { return steps_; } - uint8_t GetHits() { return hits_; } - uint8_t GetStepIndex() { return step_index_; } + uint8_t GetSteps() const { return steps_; } + uint8_t GetHits() const { return hits_; } + uint8_t GetStepIndex() const { return step_index_; } Step NextStep() { if (steps_ == 0) return REST; diff --git a/examples/Gravity/save_state.cpp b/examples/Gravity/save_state.cpp index c435df8..62895a7 100644 --- a/examples/Gravity/save_state.cpp +++ b/examples/Gravity/save_state.cpp @@ -29,10 +29,10 @@ bool StateManager::initialize(AppState& app) { ch.setDutyCycle(saved_ch_state.base_duty_cycle); ch.setOffset(saved_ch_state.base_offset); ch.setSwing(saved_ch_state.base_shuffle); - ch.setCvSource(static_cast(saved_ch_state.cv_source)); - ch.setCvDestination(static_cast(saved_ch_state.cv_destination)); - ch.setSteps(saved_ch_state.euc_steps); - ch.setHits(saved_ch_state.euc_hits); + 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.setCv1Dest(static_cast(saved_ch_state.cv2_dest)); } return true; @@ -112,10 +112,10 @@ void StateManager::_saveState(const AppState& app) { save_ch.base_duty_cycle = ch.getDutyCycle(false); save_ch.base_offset = ch.getOffset(false); save_ch.base_shuffle = ch.getSwing(); - save_ch.cv_source = static_cast(ch.getCvSource()); - save_ch.cv_destination = static_cast(ch.getCvDestination()); - save_ch.euc_steps = ch.getSteps(); - save_ch.euc_hits = ch.getHits(); + save_ch.base_euc_steps = ch.getSteps(); + save_ch.base_euc_hits = ch.getHits(); + save_ch.cv1_dest = static_cast(ch.getCv1Dest()); + save_ch.cv2_dest = static_cast(ch.getCv2Dest()); } EEPROM.put(sizeof(Metadata), save_data); } diff --git a/examples/Gravity/save_state.h b/examples/Gravity/save_state.h index 8a2659e..826fc93 100644 --- a/examples/Gravity/save_state.h +++ b/examples/Gravity/save_state.h @@ -9,7 +9,7 @@ struct AppState; // Define the constants for the current firmware. const char SKETCH_NAME[] = "Gravity"; -const byte SKETCH_VERSION = 5; +const byte SKETCH_VERSION = 6; // Define the minimum amount of time between EEPROM writes. static const unsigned long SAVE_DELAY_MS = 2000; @@ -42,10 +42,10 @@ class StateManager { byte base_duty_cycle; byte base_offset; byte base_shuffle; - byte cv_source; // Cast the CvSource enum to a byte for storage - byte cv_destination; // Cast the CvDestination enum as a byte for storage - byte euc_steps; - byte euc_hits; + 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 { -- 2.39.5 From db50132c2855222edaa3402bd1fcd9404594bed1 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Thu, 3 Jul 2025 07:57:18 -0700 Subject: [PATCH 35/69] update comments and minor fixes. --- examples/Gravity/Gravity.ino | 34 ++++++++++++++++++++++++++-------- examples/Gravity/channel.h | 12 ++++++------ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/examples/Gravity/Gravity.ino b/examples/Gravity/Gravity.ino index 58069c0..755d8ea 100644 --- a/examples/Gravity/Gravity.ino +++ b/examples/Gravity/Gravity.ino @@ -1,15 +1,33 @@ /** * @file Gravity.ino * @author Adam Wonak (https://github.com/awonak/) - * @brief Demo firmware for Sitka Instruments Gravity. + * @brief Alt firmware version of Gravity by Sitka Instruments. * @version 0.1 * @date 2025-05-04 * * @copyright Copyright (c) 2025 + * + * 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 to change between selecting a parameter and editing the parameter. - * Hold & Rotate to change current output channel pattern. + * Press: change between selecting a parameter and editing the parameter. + * Hold & Rotate: change current selected output channel. * * BTN1: Play/pause the internal clock. * @@ -37,7 +55,7 @@ void setup() { // Initialize the state manager. This will load settings from EEPROM stateManager.initialize(app); - InitAppState(app); + InitGravity(app); // Clock handlers. gravity.clock.AttachIntHandler(HandleIntClockTick); @@ -107,8 +125,8 @@ void HandleIntClockTick(uint32_t tick) { break; } - const uint16_t pulse_high_ticks = clock_mod_pulses[clock_index]; - const uint32_t pulse_low_ticks = tick + max((long)(pulse_high_ticks / 2), 1L); + const uint32_t pulse_high_ticks = 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(); @@ -162,7 +180,7 @@ void HandleEncoderPressed() { if (app.selected_param == PARAM_MAIN_RESET_STATE) { if (app.selected_sub_param == 0) { // Reset stateManager.reset(app); - InitAppState(app); + InitGravity(app); } } } @@ -282,7 +300,7 @@ void updateSelection(byte& param, int change, int maxValue) { // App Helper functions. // -void InitAppState(AppState& app) { +void InitGravity(AppState& app) { gravity.clock.SetTempo(app.tempo); gravity.clock.SetSource(app.selected_source); gravity.encoder.SetReverseDirection(app.encoder_reversed); diff --git a/examples/Gravity/channel.h b/examples/Gravity/channel.h index d9a0028..55a87ac 100644 --- a/examples/Gravity/channel.h +++ b/examples/Gravity/channel.h @@ -19,11 +19,11 @@ enum CvDestination : uint8_t { CV_DEST_LAST, }; -static const int MOD_CHOICE_SIZE = 21; +static const byte MOD_CHOICE_SIZE = 21; // Negative for multiply, positive for divide. -static const int clock_mod[MOD_CHOICE_SIZE] PROGMEM = {-24, -12, -8, -6, -4, -3, -2, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 24, 32, 64, 128}; +static const int CLOCK_MOD[MOD_CHOICE_SIZE] PROGMEM = {-24, -12, -8, -6, -4, -3, -2, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 24, 32, 64, 128}; // 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 = {4, 8, 12, 16, 24, 32, 48, 96, 192, 288, 384, 480, 576, 1152, 672, 768, 1536, 2304, 3072, 6144, 12288}; +static const int CLOCK_MOD_PULSES[MOD_CHOICE_SIZE] PROGMEM = {4, 8, 12, 16, 24, 32, 48, 96, 192, 288, 384, 480, 576, 1152, 672, 768, 1536, 2304, 3072, 6144, 12288}; class Channel { public: @@ -111,7 +111,7 @@ class Channel { 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 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; } @@ -126,7 +126,7 @@ class Channel { */ void processClockTick(uint32_t tick, DigitalOutput& output) { // Calculate output duty cycle state using cv modded values to determine pulse counts. - const uint16_t mod_pulses = pgm_read_word_near(&clock_mod_pulses[cvmod_clock_mod_index]); + const uint16_t mod_pulses = pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]); const uint16_t duty_pulses = max((long)((mod_pulses * (100L - cvmod_duty_cycle)) / 100L), 1L); const uint16_t offset_pulses = (long)((mod_pulses * (100L - cvmod_offset)) / 100L); @@ -144,7 +144,7 @@ class Channel { // Step check if (current_tick_offset % mod_pulses == 0) { bool hit = cvmod_probability >= random(0, 100); - // Euclidean rhythm check + // Euclidean rhythm hit check switch (pattern.NextStep()) { case Pattern::REST: // Rest when active or fall back to probability hit = false; -- 2.39.5 From 17a9212fc440ba7025242cd1387e1ba036ba93d2 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Thu, 3 Jul 2025 08:45:53 -0700 Subject: [PATCH 36/69] pre-calculate clock pulse mods to improve ISR performance --- examples/Gravity/channel.h | 92 +++++++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 27 deletions(-) diff --git a/examples/Gravity/channel.h b/examples/Gravity/channel.h index 55a87ac..ca2648d 100644 --- a/examples/Gravity/channel.h +++ b/examples/Gravity/channel.h @@ -48,41 +48,49 @@ class Channel { cvmod_swing = base_swing; 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 (cv1_dest != CV_DEST_MOD && cv2_dest != CV_DEST_MOD) { + if (!isCvModActive()) { cvmod_clock_mod_index = base_clock_mod_index; + _recalculatePulses(); } } void setProbability(int prob) { base_probability = constrain(prob, 0, 100); - if (cv1_dest != CV_DEST_PROB && cv2_dest != CV_DEST_PROB) { + if (!isCvModActive()) { cvmod_probability = base_probability; + _recalculatePulses(); } } void setDutyCycle(int duty) { base_duty_cycle = constrain(duty, 1, 99); - if (cv1_dest != CV_DEST_DUTY && cv2_dest != CV_DEST_DUTY) { + if (!isCvModActive()) { cvmod_duty_cycle = base_duty_cycle; + _recalculatePulses(); } } void setOffset(int off) { base_offset = constrain(off, 0, 99); - if (cv1_dest != CV_DEST_OFFSET && cv2_dest != CV_DEST_OFFSET) { + if (!isCvModActive()) { cvmod_offset = base_offset; + _recalculatePulses(); } } void setSwing(int val) { base_swing = constrain(val, 50, 95); - if (cv1_dest != CV_DEST_SWING && cv2_dest != CV_DEST_SWING) { + if (!isCvModActive()) { cvmod_swing = base_swing; + _recalculatePulses(); } } @@ -125,21 +133,16 @@ class Channel { * @param output The output object to be modified. */ void processClockTick(uint32_t tick, DigitalOutput& output) { - // Calculate output duty cycle state using cv modded values to determine pulse counts. const uint16_t mod_pulses = pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]); - const uint16_t duty_pulses = max((long)((mod_pulses * (100L - cvmod_duty_cycle)) / 100L), 1L); - const uint16_t offset_pulses = (long)((mod_pulses * (100L - cvmod_offset)) / 100L); + // Conditionally apply swing on down beats. uint16_t swing_pulses = 0; - // Check step increment for odd beats. - if (cvmod_swing > 50 && (tick / mod_pulses) % 2 == 1) { - int shifted_swing = cvmod_swing - 50; - swing_pulses = (long)((mod_pulses * (100L - shifted_swing)) / 100L); + if (_swing_pulse_amount > 0 && (tick / mod_pulses) % 2 == 1) { + swing_pulses = _swing_pulse_amount; } - const uint32_t current_tick_offset = tick + offset_pulses + swing_pulses; - // 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) { @@ -160,16 +163,20 @@ class Channel { } // Duty cycle low check - const uint32_t duty_cycle_end_tick = tick + duty_pulses + offset_pulses + swing_pulses; + 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) { - // Calculate and store cv modded values using bipolar mapping. - // Default to base value if not the current CV destination. - // 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. @@ -178,35 +185,61 @@ class Channel { // 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, -10, 10); + int dest_mod = _calculateMod(CV_DEST_MOD, cv1_val, cv2_val, -10, 10); 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); + 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); + 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); + 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); + 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); + int step_mod = _calculateMod(CV_DEST_EUC_STEPS, cv1_val, cv2_val, 0, MAX_PATTERN_LEN); pattern.SetSteps(base_euc_steps + step_mod); - int hit_mod = calculateMod(CV_DEST_EUC_HITS, cv1_val, cv2_val, 0, MAX_PATTERN_LEN); + int hit_mod = _calculateMod(CV_DEST_EUC_HITS, cv1_val, cv2_val, 0, MAX_PATTERN_LEN); 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 _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; @@ -229,6 +262,11 @@ class Channel { // Euclidean pattern Pattern pattern; + + // 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 -- 2.39.5 From 74d98fed139dd6fe92396854dec5ee96ded4fac1 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Thu, 3 Jul 2025 09:08:38 -0700 Subject: [PATCH 37/69] add missing midi echo --- clock.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/clock.h b/clock.h index 00bd213..5401d1a 100644 --- a/clock.h +++ b/clock.h @@ -163,10 +163,12 @@ class Clock { break; case MIDI_STOP: uClock.stop(); + sendMIDIStop(); break; case MIDI_START: case MIDI_CONTINUE: uClock.start(); + sendMIDIStart(); break; } } -- 2.39.5 From d21c0a810fea87ddc8bf9b58bff227adf5b69216 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Thu, 3 Jul 2025 09:12:15 -0700 Subject: [PATCH 38/69] Add more clock mult/div options and improve documentation to make it easier to modify the list. --- examples/Gravity/channel.h | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/examples/Gravity/channel.h b/examples/Gravity/channel.h index ca2648d..f7d5f46 100644 --- a/examples/Gravity/channel.h +++ b/examples/Gravity/channel.h @@ -19,11 +19,28 @@ enum CvDestination : uint8_t { CV_DEST_LAST, }; -static const byte MOD_CHOICE_SIZE = 21; -// Negative for multiply, positive for divide. -static const int CLOCK_MOD[MOD_CHOICE_SIZE] PROGMEM = {-24, -12, -8, -6, -4, -3, -2, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 24, 32, 64, 128}; -// 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 = {4, 8, 12, 16, 24, 32, 48, 96, 192, 288, 384, 480, 576, 1152, 672, 768, 1536, 2304, 3072, 6144, 12288}; +static const byte MOD_CHOICE_SIZE = 25; + +// Negative numbers are multipliers, positive are divisors. +static const int CLOCK_MOD[MOD_CHOICE_SIZE] PROGMEM = { + // Multipliers + -24, -16, -12, -8, -6, -4, -3, -2, + // Internal Clock Unity + 1, + // Divisors + 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 24, 32, 64, 128 +}; + +// This represents the number of clock pulses for a 96 PPQN clock source +// that match the above div/mult mods. +static const int CLOCK_MOD_PULSES[MOD_CHOICE_SIZE] PROGMEM = { + // Multiplier Pulses (96 / X) + 4, 6, 8, 12, 16, 24, 32, 48, + // Internal Clock Pulses + 96, + // Divisor Pulses (96 * X) + 192, 288, 384, 480, 576, 672, 768, 864, 960, 1056, 1152, 1536, 2304, 3072, 6144, 12288 +}; class Channel { public: @@ -194,7 +211,7 @@ class Channel { return; } - int dest_mod = _calculateMod(CV_DEST_MOD, cv1_val, cv2_val, -10, 10); + 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); -- 2.39.5 From d2228af55fd22064021e63741fade3965deccac2 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Fri, 4 Jul 2025 08:22:25 -0700 Subject: [PATCH 39/69] bug fix: must have curly braces when declaring new variables inside CASE statement. --- examples/Gravity/Gravity.ino | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/Gravity/Gravity.ino b/examples/Gravity/Gravity.ino index 755d8ea..d153253 100644 --- a/examples/Gravity/Gravity.ino +++ b/examples/Gravity/Gravity.ino @@ -232,13 +232,14 @@ void editMainParameter(int val) { gravity.clock.SetSource(app.selected_source); break; } - case PARAM_MAIN_PULSE: + 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(); } + } case PARAM_MAIN_ENCODER_DIR: updateSelection(app.selected_sub_param, val, 2); break; -- 2.39.5 From 14aad8285d37e6ddbece5bc83608d5c41758eacc Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Fri, 4 Jul 2025 17:33:57 +0000 Subject: [PATCH 40/69] Introduce Save/Load banks for storing different preset settings. (#11) This also includes a lot of minor fixes. Reviewed-on: https://git.pinkduck.xyz/adam/libGravity/pulls/11 Co-authored-by: Adam Wonak Co-committed-by: Adam Wonak --- examples/Gravity/Gravity.ino | 40 +++++++--- examples/Gravity/app_state.h | 9 ++- examples/Gravity/channel.h | 25 +++--- examples/Gravity/display.h | 98 +++++++++++++++-------- examples/Gravity/euclidean.h | 2 +- examples/Gravity/save_state.cpp | 136 +++++++++++++++++++------------- examples/Gravity/save_state.h | 25 ++++-- 7 files changed, 216 insertions(+), 119 deletions(-) diff --git a/examples/Gravity/Gravity.ino b/examples/Gravity/Gravity.ino index d153253..d0d21d8 100644 --- a/examples/Gravity/Gravity.ino +++ b/examples/Gravity/Gravity.ino @@ -6,20 +6,20 @@ * @date 2025-05-04 * * @copyright Copyright (c) 2025 - * + * * 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 + * 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 + * 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 + * 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 @@ -172,11 +172,24 @@ 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) { bool reversed = app.selected_sub_param == 1; gravity.encoder.SetReverseDirection(reversed); } - // Reset state + if (app.selected_param == PARAM_MAIN_SAVE_DATA) { + if (app.selected_sub_param < MAX_SAVE_SLOTS) { + app.selected_save_slot = app.selected_sub_param; + stateManager.saveData(app); + } + } + if (app.selected_param == PARAM_MAIN_LOAD_DATA) { + if (app.selected_sub_param < MAX_SAVE_SLOTS) { + 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); @@ -184,10 +197,11 @@ void HandleEncoderPressed() { } } } - // Only mark dirty when leaving editing mode. + // Only mark dirty and reset selected_sub_param when leaving editing mode. stateManager.markDirty(); + app.selected_sub_param = 0; } - app.selected_sub_param = 0; + app.editing_param = !app.editing_param; app.refresh_screen = true; } @@ -224,7 +238,6 @@ void editMainParameter(int val) { 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); @@ -239,10 +252,15 @@ void editMainParameter(int val) { 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, MAX_SAVE_SLOTS + 1); + break; case PARAM_MAIN_RESET_STATE: updateSelection(app.selected_sub_param, val, 2); break; diff --git a/examples/Gravity/app_state.h b/examples/Gravity/app_state.h index a52be4b..c52fc26 100644 --- a/examples/Gravity/app_state.h +++ b/examples/Gravity/app_state.h @@ -12,9 +12,10 @@ struct AppState { bool refresh_screen = true; bool editing_param = false; byte selected_param = 0; - byte selected_sub_param = 0; - byte selected_channel = 0; // 0=tempo, 1-6=output channel - byte selected_shuffle = 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; Channel channel[Gravity::OUTPUT_COUNT]; @@ -31,6 +32,8 @@ enum ParamsMainPage : uint8_t { PARAM_MAIN_SOURCE, PARAM_MAIN_PULSE, PARAM_MAIN_ENCODER_DIR, + PARAM_MAIN_SAVE_DATA, + PARAM_MAIN_LOAD_DATA, PARAM_MAIN_RESET_STATE, PARAM_MAIN_LAST, }; diff --git a/examples/Gravity/channel.h b/examples/Gravity/channel.h index f7d5f46..309ce98 100644 --- a/examples/Gravity/channel.h +++ b/examples/Gravity/channel.h @@ -24,23 +24,23 @@ static const byte MOD_CHOICE_SIZE = 25; // Negative numbers are multipliers, positive are divisors. static const int CLOCK_MOD[MOD_CHOICE_SIZE] PROGMEM = { // Multipliers - -24, -16, -12, -8, -6, -4, -3, -2, + -24, -16, -12, -8, -6, -4, -3, -2, // Internal Clock Unity - 1, + 1, // Divisors - 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 24, 32, 64, 128 -}; + 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 24, 32, 64, 128}; -// This represents the number of clock pulses for a 96 PPQN clock source +// This represents the number of clock pulses for a 96 PPQN clock source // that match the above div/mult mods. static const int CLOCK_MOD_PULSES[MOD_CHOICE_SIZE] PROGMEM = { // Multiplier Pulses (96 / X) - 4, 6, 8, 12, 16, 24, 32, 48, + 4, 6, 8, 12, 16, 24, 32, 48, // Internal Clock Pulses - 96, + 96, // Divisor Pulses (96 * X) - 192, 288, 384, 480, 576, 672, 768, 864, 960, 1056, 1152, 1536, 2304, 3072, 6144, 12288 -}; + 192, 288, 384, 480, 576, 672, 768, 864, 960, 1056, 1152, 1536, 2304, 3072, 6144, 12288}; + +static const byte DEFAULT_CLOCK_MOD_INDEX = 8; // x1 or 96 PPQN. class Channel { public: @@ -50,7 +50,7 @@ class Channel { void Init() { // Reset base values to their defaults - base_clock_mod_index = 7; + base_clock_mod_index = DEFAULT_CLOCK_MOD_INDEX; base_probability = 100; base_duty_cycle = 50; base_offset = 0; @@ -64,6 +64,9 @@ class Channel { 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. @@ -211,7 +214,7 @@ class Channel { return; } - int dest_mod = _calculateMod(CV_DEST_MOD, cv1_val, cv2_val, -(MOD_CHOICE_SIZE/2), MOD_CHOICE_SIZE/2); + 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); diff --git a/examples/Gravity/display.h b/examples/Gravity/display.h index 9349dd0..c000dde 100644 --- a/examples/Gravity/display.h +++ b/examples/Gravity/display.h @@ -4,6 +4,7 @@ #include #include "app_state.h" +#include "save_state.h" // // UI Display functions for drawing the UI to the OLED display. @@ -33,32 +34,33 @@ const uint8_t TEXT_FONT[437] U8G2_FONT_SECTION("velvetscreen") PROGMEM = /* * Font: STK-L.bdf 36pt * https://stncrn.github.io/u8g2-unifont-helper/ - * "%/0123456789ACDEFINORSTUVXx" + * "%/0123456789ABCDEFILNORSTUVXx" */ -const uint8_t LARGE_FONT[715] U8G2_FONT_SECTION("stk-l") = - "\33\0\4\4\4\5\2\1\6\20\30\0\0\27\0\0\0\1\77\0\0\2\256%'\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\1C\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\0I" - "\7so\302\37$N#}\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\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\42\216\37\317\261DKH\221\30\67b\334\210\261" - "c)M\226-\331\301c\307\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\0V\26\177\375\302H\373\377\345\210qCH\221\241\212" - "\4\271\223e\207\1X)~\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 CGH\240\61E\312\14\222)\6" - "Y\64\0\0\0\0\4\377\377\0"; +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 @@ -151,6 +153,10 @@ void drawMenuItems(String menu_items[], int menu_size) { } } +// 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); } + // Display an indicator when swing percentage matches a musical note. void swingDivisionMark() { auto& ch = GetSelectedChannel(); @@ -158,17 +164,25 @@ void swingDivisionMark() { case 58: // 1/32nd case 66: // 1/16th case 75: // 1/8th - gravity.display.drawBox(56, 4, 4, 4); + solidTick(); break; case 54: // 1/32nd tripplet case 62: // 1/16th tripplet case 71: // 1/8th tripplet - gravity.display.drawBox(56, 4, 4, 4); - gravity.display.drawBox(57, 5, 2, 2); + hollowTick(); break; } } +// Human friendly display value for save slot. +String displaySaveSlot(int slot) { + if (slot >= 0 && slot < MAX_SAVE_SLOTS / 2) { + return String("A") + String(slot + 1); + } else if (slot >= MAX_SAVE_SLOTS / 2 && slot <= MAX_SAVE_SLOTS) { + return String("B") + String(slot - (MAX_SAVE_SLOTS / 2) + 1); + } +} + // Main display functions void DisplayMainPage() { @@ -229,17 +243,37 @@ void DisplayMainPage() { mainText = F("DIR"); subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("REVERSED"); break; - case PARAM_MAIN_RESET_STATE: - mainText = F("RST"); - subText = app.selected_sub_param == 0 ? F("RESET ALL") : F("BACK"); + case PARAM_MAIN_SAVE_DATA: + case PARAM_MAIN_LOAD_DATA: + if (app.selected_sub_param == 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"); + } } 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("RESET")}; + String menu_items[PARAM_MAIN_LAST] = {F("TEMPO"), F("SOURCE"), F("PULSE OUT"), F("ENCODER DIR"), F("SAVE"), F("LOAD"), F("RESET")}; drawMenuItems(menu_items, PARAM_MAIN_LAST); } diff --git a/examples/Gravity/euclidean.h b/examples/Gravity/euclidean.h index 9c744c3..33dfe56 100644 --- a/examples/Gravity/euclidean.h +++ b/examples/Gravity/euclidean.h @@ -66,7 +66,7 @@ class Pattern { // Update the euclidean rhythm pattern using bitmap void updatePattern() { - pattern_bitmap_ = 0; // Clear the bitmap + pattern_bitmap_ = 0; // Clear the bitmap if (steps_ == 0) return; diff --git a/examples/Gravity/save_state.cpp b/examples/Gravity/save_state.cpp index 62895a7..3c1e9d9 100644 --- a/examples/Gravity/save_state.cpp +++ b/examples/Gravity/save_state.cpp @@ -4,49 +4,50 @@ #include "app_state.h" +// Calculate the starting address for EepromData, leaving space for metadata. +static const int EEPROM_DATA_START_ADDR = sizeof(StateManager::Metadata); + StateManager::StateManager() : _isDirty(false), _lastChangeTime(0) {} bool StateManager::initialize(AppState& app) { if (_isDataValid()) { - static EepromData load_data; - EEPROM.get(sizeof(Metadata), load_data); - - // Restore main app state - app.tempo = load_data.tempo; - app.encoder_reversed = load_data.encoder_reversed; - app.selected_param = load_data.selected_param; - app.selected_channel = load_data.selected_channel; - app.selected_source = static_cast(load_data.selected_source); - app.selected_pulse = static_cast(load_data.selected_pulse); - - // Loop through and restore each channel's state. - 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_shuffle); - 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.setCv1Dest(static_cast(saved_ch_state.cv2_dest)); - } - - return true; + // Load data from the transient slot. + return loadData(app, MAX_SAVE_SLOTS); } else { + // EEPROM does not contain save data for this firmware & version. + // Initialize eeprom and save default patter to all save slots. reset(app); + _saveMetadata(); + // MAX_SAVE_SLOTS slot is reserved for transient state. + for (int i = 0; i <= MAX_SAVE_SLOTS; i++) { + app.selected_save_slot = i; + _saveState(app, i); + } return false; } } -void StateManager::_save(const AppState& app) { - // Ensure interrupts do not cause corrupt data writes. - noInterrupts(); - _saveState(app); - interrupts(); +bool StateManager::loadData(AppState& app, byte slot_index) { + if (slot_index >= MAX_SAVE_SLOTS) return false; + + _loadState(app, slot_index); + + return true; +} + +void StateManager::saveData(const AppState& app) { + if (app.selected_save_slot >= MAX_SAVE_SLOTS) return; + + _saveState(app, app.selected_save_slot); + _isDirty = false; +} + +void StateManager::update(const AppState& app) { + if (_isDirty && (millis() - _lastChangeTime > SAVE_DELAY_MS)) { + // MAX_SAVE_SLOTS slot is reserved for transient state. + _saveState(app, MAX_SAVE_SLOTS); + _isDirty = false; + } } void StateManager::reset(AppState& app) { @@ -56,27 +57,15 @@ void StateManager::reset(AppState& app) { app.selected_channel = 0; app.selected_source = Clock::SOURCE_INTERNAL; app.selected_pulse = Clock::PULSE_PPQN_24; + app.selected_save_slot = 0; for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { app.channel[i].Init(); } - noInterrupts(); - _saveMetadata(); // Write the new metadata - _saveState(app); // Write the new (default) app state - interrupts(); - _isDirty = false; } -void StateManager::update(const AppState& app) { - // Check if a save is pending and if enough time has passed. - if (_isDirty && (millis() - _lastChangeTime > SAVE_DELAY_MS)) { - _save(app); - _isDirty = false; // Clear the flag, we are now "clean". - } -} - void StateManager::markDirty() { _isDirty = true; _lastChangeTime = millis(); @@ -90,39 +79,76 @@ bool StateManager::_isDataValid() { return name_match && version_match; } -void StateManager::_saveState(const AppState& app) { +void StateManager::_saveState(const AppState& app, byte slot_index) { + if (app.selected_save_slot >= MAX_SAVE_SLOTS) return; + + noInterrupts(); static EepromData save_data; - // Populate main app state save_data.tempo = app.tempo; save_data.encoder_reversed = app.encoder_reversed; save_data.selected_param = app.selected_param; save_data.selected_channel = app.selected_channel; save_data.selected_source = static_cast(app.selected_source); save_data.selected_pulse = static_cast(app.selected_pulse); + save_data.selected_save_slot = app.selected_save_slot; - // Loop through and populate each channel's state for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { const auto& ch = app.channel[i]; auto& save_ch = save_data.channel_data[i]; - - // Use the getters with 'withCvMod = false' to get the base values 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_shuffle = ch.getSwing(); - save_ch.base_euc_steps = ch.getSteps(); - save_ch.base_euc_hits = ch.getHits(); + 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()); } - EEPROM.put(sizeof(Metadata), save_data); + + int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData)); + EEPROM.put(address, save_data); + interrupts(); +} + +void StateManager::_loadState(AppState& app, byte slot_index) { + 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.encoder_reversed = load_data.encoder_reversed; + app.selected_param = load_data.selected_param; + app.selected_channel = load_data.selected_channel; + app.selected_source = static_cast(load_data.selected_source); + app.selected_pulse = static_cast(load_data.selected_pulse); + app.selected_save_slot = slot_index; + + for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { + auto& ch = app.channel[i]; + 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(); } void StateManager::_saveMetadata() { + noInterrupts(); Metadata current_meta; strcpy(current_meta.sketch_name, SKETCH_NAME); current_meta.version = SKETCH_VERSION; EEPROM.put(0, current_meta); + interrupts(); } diff --git a/examples/Gravity/save_state.h b/examples/Gravity/save_state.h index 826fc93..a8d5a7e 100644 --- a/examples/Gravity/save_state.h +++ b/examples/Gravity/save_state.h @@ -9,13 +9,21 @@ struct AppState; // Define the constants for the current firmware. const char SKETCH_NAME[] = "Gravity"; -const byte SKETCH_VERSION = 6; +const byte SKETCH_VERSION = 7; + +// Number of available save slots. +const byte MAX_SAVE_SLOTS = 10; // Define the minimum amount of time between EEPROM writes. static const unsigned long SAVE_DELAY_MS = 2000; /** * @brief Manages saving and loading of the application state to and from EEPROM. + * The number of user slots is defined by MAX_SAVE_SLOTS, and one additional slot + * 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: @@ -23,6 +31,10 @@ class 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. @@ -30,18 +42,17 @@ class StateManager { // Indicate that state has changed and we should save. void markDirty(); - private: // This struct holds the data that identifies the firmware version. struct Metadata { - char sketch_name[16]; byte version; + char sketch_name[16]; }; struct ChannelState { byte base_clock_mod_index; byte base_probability; byte base_duty_cycle; byte base_offset; - byte base_shuffle; + byte base_swing; byte base_euc_steps; byte base_euc_hits; byte cv1_dest; // Cast the CvDestination enum as a byte for storage @@ -55,13 +66,15 @@ class StateManager { byte selected_channel; byte selected_source; byte selected_pulse; + byte selected_save_slot; ChannelState channel_data[Gravity::OUTPUT_COUNT]; }; - void _save(const AppState& app); + private: bool _isDataValid(); - void _saveState(const AppState& app); void _saveMetadata(); + void _saveState(const AppState& app, byte slot_index); + void _loadState(AppState& app, byte slot_index); bool _isDirty; unsigned long _lastChangeTime; -- 2.39.5 From ae726313a04c32a023bea32e19f6eec7f52e85af Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Fri, 4 Jul 2025 10:44:03 -0700 Subject: [PATCH 41/69] use shift button to change channel when held + rotate. --- examples/Gravity/Gravity.ino | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/examples/Gravity/Gravity.ino b/examples/Gravity/Gravity.ino index d153253..d6f00f4 100644 --- a/examples/Gravity/Gravity.ino +++ b/examples/Gravity/Gravity.ino @@ -24,14 +24,16 @@ * 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 the internal clock. + * BTN1: + * Play/pause - start or stop the internal clock. * - * BTN2: Stop all clocks. + * BTN2: + * Shift - hold and rotate encoder to change current selected output channel. * */ @@ -68,7 +70,6 @@ void setup() { // Button press handlers. gravity.play_button.AttachPressHandler(HandlePlayPressed); - gravity.shift_button.AttachPressHandler(HandleShiftPressed); } void loop() { @@ -162,12 +163,6 @@ void HandlePlayPressed() { 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) { @@ -193,6 +188,12 @@ void HandleEncoderPressed() { } 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; -- 2.39.5 From 14d1c497b32ac91bac7461415c65e5a83737365d Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Fri, 4 Jul 2025 10:54:16 -0700 Subject: [PATCH 42/69] Add clock reset behavior to EXT when internally clocked. --- clock.h | 5 +++++ examples/Gravity/Gravity.ino | 19 +++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/clock.h b/clock.h index 5401d1a..8fa084e 100644 --- a/clock.h +++ b/clock.h @@ -145,6 +145,11 @@ class Clock { 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; diff --git a/examples/Gravity/Gravity.ino b/examples/Gravity/Gravity.ino index 7cc17d7..a7842b2 100644 --- a/examples/Gravity/Gravity.ino +++ b/examples/Gravity/Gravity.ino @@ -35,6 +35,14 @@ * BTN2: * Shift - hold and rotate encoder to change current selected output channel. * + * EXT: + * External clock input. When Gravity is set to INTERNAL clock mode, this + * input is used to reset clocks. + * + * CV1: + * CV2: + * External analog input used to provide modulation to any channel parameter. + * */ #include @@ -143,11 +151,14 @@ void HandleIntClockTick(uint32_t tick) { } void HandleExtClockTick() { - // Ignore tick if not using external source. - if (!gravity.clock.ExternalSource()) { - return; + if (gravity.clock.InternalSource()) { + // Use EXT as Reset when internally clocked. + ResetOutputs(); + gravity.clock.Reset(); + } else { + // Register clock tick. + gravity.clock.Tick(); } - gravity.clock.Tick(); app.refresh_screen = true; } -- 2.39.5 From f6b4b8a2ad63c75dfc51f81318a757747d62094b Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Fri, 4 Jul 2025 10:57:22 -0700 Subject: [PATCH 43/69] migrate Gravity firmware into a new dedicated firmware directory. --- {examples => firmware}/Gravity/Gravity.ino | 0 {examples => firmware}/Gravity/app_state.h | 0 {examples => firmware}/Gravity/channel.h | 0 {examples => firmware}/Gravity/display.h | 0 {examples => firmware}/Gravity/euclidean.h | 0 {examples => firmware}/Gravity/save_state.cpp | 0 {examples => firmware}/Gravity/save_state.h | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename {examples => firmware}/Gravity/Gravity.ino (100%) rename {examples => firmware}/Gravity/app_state.h (100%) rename {examples => firmware}/Gravity/channel.h (100%) rename {examples => firmware}/Gravity/display.h (100%) rename {examples => firmware}/Gravity/euclidean.h (100%) rename {examples => firmware}/Gravity/save_state.cpp (100%) rename {examples => firmware}/Gravity/save_state.h (100%) diff --git a/examples/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino similarity index 100% rename from examples/Gravity/Gravity.ino rename to firmware/Gravity/Gravity.ino diff --git a/examples/Gravity/app_state.h b/firmware/Gravity/app_state.h similarity index 100% rename from examples/Gravity/app_state.h rename to firmware/Gravity/app_state.h diff --git a/examples/Gravity/channel.h b/firmware/Gravity/channel.h similarity index 100% rename from examples/Gravity/channel.h rename to firmware/Gravity/channel.h diff --git a/examples/Gravity/display.h b/firmware/Gravity/display.h similarity index 100% rename from examples/Gravity/display.h rename to firmware/Gravity/display.h diff --git a/examples/Gravity/euclidean.h b/firmware/Gravity/euclidean.h similarity index 100% rename from examples/Gravity/euclidean.h rename to firmware/Gravity/euclidean.h diff --git a/examples/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp similarity index 100% rename from examples/Gravity/save_state.cpp rename to firmware/Gravity/save_state.cpp diff --git a/examples/Gravity/save_state.h b/firmware/Gravity/save_state.h similarity index 100% rename from examples/Gravity/save_state.h rename to firmware/Gravity/save_state.h -- 2.39.5 From ab71ac9c3701d6c54bfa4580a41610d4d508067f Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Fri, 4 Jul 2025 14:05:58 -0700 Subject: [PATCH 44/69] Add copyright license information --- LICENSE | 21 +++++++++++++++++++++ analog_input.h | 2 +- button.h | 8 +++----- clock.h | 4 ++-- digital_output.h | 4 ++-- encoder.h | 4 ++-- firmware/Gravity/Gravity.ino | 7 ++++--- firmware/Gravity/app_state.h | 11 +++++++++++ firmware/Gravity/channel.h | 11 +++++++++++ firmware/Gravity/display.h | 11 +++++++++++ firmware/Gravity/euclidean.h | 11 +++++++++++ firmware/Gravity/save_state.cpp | 11 +++++++++++ firmware/Gravity/save_state.h | 11 +++++++++++ gravity.cpp | 4 ++-- gravity.h | 11 +++++++++++ peripherials.h | 5 +++-- uClock.cpp | 6 ++++++ uClock.h | 6 ++++++ 18 files changed, 129 insertions(+), 19 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..257a4bc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Adam Wonak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/analog_input.h b/analog_input.h index 7838653..496899b 100644 --- a/analog_input.h +++ b/analog_input.h @@ -5,7 +5,7 @@ * @version 0.1 * @date 2025-05-23 * - * @copyright Copyright (c) 2025 + * @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com * */ #ifndef ANALOG_INPUT_H diff --git a/button.h b/button.h index 16bd9a9..c66ba7c 100644 --- a/button.h +++ b/button.h @@ -1,13 +1,11 @@ /** * @file button.h * @author Adam Wonak (https://github.com/awonak) - * @brief for interacting with trigger / gate inputs. + * @brief Wrapper class for interacting with trigger / gate inputs. * @version 0.1 * @date 2025-04-20 - * - * Provide methods to convey curent state (HIGH / LOW) and change in state (disengaged, engageing, engaged, disengaging). - * - * @copyright Copyright (c) 2025 + * + * @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com * */ #ifndef BUTTON_H diff --git a/clock.h b/clock.h index 8fa084e..9cb2ad8 100644 --- a/clock.h +++ b/clock.h @@ -4,8 +4,8 @@ * @brief Wrapper Class for clock timing functions. * @version 0.1 * @date 2025-05-04 - * - * @copyright Copyright (c) 2025 + * + * @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com * */ diff --git a/digital_output.h b/digital_output.h index 38be3d3..9c4cfc8 100644 --- a/digital_output.h +++ b/digital_output.h @@ -4,8 +4,8 @@ * @brief Class for interacting with trigger / gate outputs. * @version 0.1 * @date 2025-04-17 - * - * @copyright Copyright (c) 2023 + * + * @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com * */ #ifndef DIGITAL_OUTPUT_H diff --git a/encoder.h b/encoder.h index e9a8635..85d52f1 100644 --- a/encoder.h +++ b/encoder.h @@ -4,8 +4,8 @@ * @brief Class for interacting with encoders. * @version 0.1 * @date 2025-04-19 - * - * @copyright Copyright (c) 2025 + * + * @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com * */ #ifndef ENCODER_DIR_H diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index a7842b2..34de653 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -2,10 +2,11 @@ * @file Gravity.ino * @author Adam Wonak (https://github.com/awonak/) * @brief Alt firmware version of Gravity by Sitka Instruments. - * @version 0.1 - * @date 2025-05-04 + * @version v2.0.1 - June 2025 awonak - Full rewrite + * @version v1.0 - August 2023 Oleksiy H - Initial release + * @date 2025-07-04 * - * @copyright Copyright (c) 2025 + * @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 diff --git a/firmware/Gravity/app_state.h b/firmware/Gravity/app_state.h index c52fc26..e7d9ab5 100644 --- a/firmware/Gravity/app_state.h +++ b/firmware/Gravity/app_state.h @@ -1,3 +1,14 @@ +/** + * @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 diff --git a/firmware/Gravity/channel.h b/firmware/Gravity/channel.h index 309ce98..6f46b28 100644 --- a/firmware/Gravity/channel.h +++ b/firmware/Gravity/channel.h @@ -1,3 +1,14 @@ +/** + * @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 diff --git a/firmware/Gravity/display.h b/firmware/Gravity/display.h index c000dde..cf37631 100644 --- a/firmware/Gravity/display.h +++ b/firmware/Gravity/display.h @@ -1,3 +1,14 @@ +/** + * @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 diff --git a/firmware/Gravity/euclidean.h b/firmware/Gravity/euclidean.h index 33dfe56..8956ffd 100644 --- a/firmware/Gravity/euclidean.h +++ b/firmware/Gravity/euclidean.h @@ -1,3 +1,14 @@ +/** + * @file euclidean.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 EUCLIDEAN_H #define EUCLIDEAN_H diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index 3c1e9d9..052779b 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -1,3 +1,14 @@ +/** + * @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 diff --git a/firmware/Gravity/save_state.h b/firmware/Gravity/save_state.h index a8d5a7e..354253a 100644 --- a/firmware/Gravity/save_state.h +++ b/firmware/Gravity/save_state.h @@ -1,3 +1,14 @@ +/** + * @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 diff --git a/gravity.cpp b/gravity.cpp index 3ef6d2b..23d79f8 100644 --- a/gravity.cpp +++ b/gravity.cpp @@ -4,8 +4,8 @@ * @brief Library for building custom scripts for the Sitka Instruments Gravity module. * @version 0.1 * @date 2025-04-19 - * - * @copyright Copyright (c) 2025 + * + * @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com * */ diff --git a/gravity.h b/gravity.h index 5701586..00539fb 100644 --- a/gravity.h +++ b/gravity.h @@ -1,3 +1,14 @@ +/** + * @file gravity.h + * @author Adam Wonak (https://github.com/awonak) + * @brief Library for building custom scripts for the Sitka Instruments Gravity module. + * @version 0.1 + * @date 2025-04-19 + * + * @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com + * + */ + #ifndef GRAVITY_H #define GRAVITY_H diff --git a/peripherials.h b/peripherials.h index 5c4ec91..9eea4b6 100644 --- a/peripherials.h +++ b/peripherials.h @@ -4,10 +4,11 @@ * @brief Arduino pin definitions for the Sitka Instruments Gravity module. * @version 0.1 * @date 2025-04-19 - * - * @copyright Copyright (c) 2025 + * + * @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com * */ + #ifndef PERIPHERIALS_H #define PERIPHERIALS_H diff --git a/uClock.cpp b/uClock.cpp index 1f53084..1003930 100755 --- a/uClock.cpp +++ b/uClock.cpp @@ -6,6 +6,12 @@ * @author Romulo Silva * @date 10/06/2017 * @license MIT - (c) 2024 - Romulo Silva - contact@midilab.co + * + * 2025-06-30 - https://github.com/awonak/uClock/tree/picoClock + * Modified by awonak to remove all unused sync callback + * methods and associated variables to dramatically reduce + * memory usage. + * See: https://github.com/midilab/uClock/issues/58 * * Permission is hereby granted, free of charge, to any person obtaining a * copy of this software and associated documentation files (the "Software"), diff --git a/uClock.h b/uClock.h index d8670b0..ad45334 100755 --- a/uClock.h +++ b/uClock.h @@ -6,6 +6,12 @@ * @author Romulo Silva * @date 10/06/2017 * @license MIT - (c) 2024 - Romulo Silva - contact@midilab.co + * + * 2025-06-30 - https://github.com/awonak/uClock/tree/picoClock + * Modified by awonak to remove all unused sync callback + * methods and associated variables to dramatically reduce + * memory usage. + * See: https://github.com/midilab/uClock/issues/58 * * Permission is hereby granted, free of charge, to any person obtaining a * copy of this software and associated documentation files (the "Software"), -- 2.39.5 From 60a7a7a349a90d339788e4ed0a261ec62a42ed5c Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Fri, 4 Jul 2025 15:21:03 -0700 Subject: [PATCH 45/69] fix example code --- examples/clock_mod/clock_mod.ino | 40 ++++++++++++++++---------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/examples/clock_mod/clock_mod.ino b/examples/clock_mod/clock_mod.ino index e9458ea..8033c0d 100644 --- a/examples/clock_mod/clock_mod.ino +++ b/examples/clock_mod/clock_mod.ino @@ -33,8 +33,8 @@ struct AppState { bool editing_param = false; int selected_param = 0; byte selected_channel = 0; // 0=tempo, 1-6=output channel - Source selected_source = SOURCE_INTERNAL; - Channel channel[OUTPUT_COUNT]; + Clock::Source selected_source = Clock::SOURCE_INTERNAL; + Channel channel[Gravity::OUTPUT_COUNT]; }; AppState app; @@ -123,7 +123,7 @@ void loop() { // void HandleIntClockTick(uint32_t tick) { - for (int i = 0; i < OUTPUT_COUNT; i++) { + for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { auto& channel = app.channel[i]; auto& output = gravity.outputs[i]; @@ -178,7 +178,7 @@ void HandleEncoderPressed() { app.refresh_screen = true; } -void HandleRotate(Direction dir, int val) { +void HandleRotate(int val) { if (!app.editing_param) { // Navigation Mode const int max_param = (app.selected_channel == 0) ? PARAM_MAIN_LAST : PARAM_CH_LAST; @@ -188,17 +188,17 @@ void HandleRotate(Direction dir, int val) { if (app.selected_channel == 0) { editMainParameter(val); } else { - editChannelParameter(dir, val); + editChannelParameter(val); } } app.refresh_screen = true; } -void HandlePressedRotate(Direction dir, int val) { - if (dir == DIRECTION_INCREMENT && app.selected_channel < OUTPUT_COUNT) { +void HandlePressedRotate(int val) { + if (val > 0 && app.selected_channel < Gravity::OUTPUT_COUNT) { app.selected_channel++; - } else if (dir == DIRECTION_DECREMENT && app.selected_channel > 0) { + } else if (val < 0 && app.selected_channel > 0) { app.selected_channel--; } app.selected_param = 0; @@ -216,21 +216,21 @@ void editMainParameter(int val) { case PARAM_MAIN_SOURCE: { int source = static_cast(app.selected_source); - updateSelection(source, val, SOURCE_LAST); - app.selected_source = static_cast(source); + updateSelection(source, val, Clock::SOURCE_LAST); + app.selected_source = static_cast(source); gravity.clock.SetSource(app.selected_source); break; } } } -void editChannelParameter(Direction dir, int val) { +void editChannelParameter(int val) { auto& ch = GetSelectedChannel(); switch (static_cast(app.selected_param)) { case PARAM_CH_MOD: - if (dir == DIRECTION_INCREMENT && ch.clock_mod_index < MOD_CHOICE_SIZE - 1) { + if (val > 0 && ch.clock_mod_index < MOD_CHOICE_SIZE - 1) { ch.clock_mod_index++; - } else if (dir == DIRECTION_DECREMENT && ch.clock_mod_index > 0) { + } else if (val < 0 && ch.clock_mod_index > 0) { ch.clock_mod_index--; } break; @@ -265,7 +265,7 @@ Channel& GetSelectedChannel() { } void ResetOutputs() { - for (int i = 0; i < OUTPUT_COUNT; i++) { + for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { gravity.outputs[i].Low(); } } @@ -311,7 +311,7 @@ void DisplayMainPage() { if (app.selected_param == 0) { // Serial MIID is too unstable to display bpm in real time. - if (app.selected_source == SOURCE_EXTERNAL_MIDI) { + if (app.selected_source == Clock::SOURCE_EXTERNAL_MIDI) { sprintf(mainText, "%s", "EXT"); } else { sprintf(mainText, "%d", gravity.clock.Tempo()); @@ -319,19 +319,19 @@ void DisplayMainPage() { subText = "BPM"; } else if (app.selected_param == 1) { switch (app.selected_source) { - case SOURCE_INTERNAL: + case Clock::SOURCE_INTERNAL: sprintf(mainText, "%s", "INT"); subText = "Clock"; break; - case SOURCE_EXTERNAL_PPQN_24: + case Clock::SOURCE_EXTERNAL_PPQN_24: sprintf(mainText, "%s", "EXT"); subText = "24 PPQN"; break; - case SOURCE_EXTERNAL_PPQN_4: + case Clock::SOURCE_EXTERNAL_PPQN_4: sprintf(mainText, "%s", "EXT"); subText = "4 PPQN"; break; - case SOURCE_EXTERNAL_MIDI: + case Clock::SOURCE_EXTERNAL_MIDI: sprintf(mainText, "%s", "EXT"); subText = "MIDI"; break; @@ -399,7 +399,7 @@ void DisplaySelectedChannel() { gravity.display.drawHLine(1, boxY, SCREEN_WIDTH - 2); gravity.display.drawVLine(SCREEN_WIDTH - 2, boxY, boxHeight); - for (int i = 0; i < OUTPUT_COUNT + 1; i++) { + for (int i = 0; i < Gravity::OUTPUT_COUNT + 1; i++) { // Draw box frame or filled selected box. gravity.display.setDrawColor(1); (app.selected_channel == i) -- 2.39.5 From 385ce85da3b37db8f9db23f0c62902fe69484d21 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sun, 13 Jul 2025 12:00:37 -0700 Subject: [PATCH 46/69] add const for pulse clock mod choices. update git ignore and readme for build command. --- .gitignore | 3 ++- README.md | 5 +++++ firmware/Gravity/Gravity.ino | 6 +++--- firmware/Gravity/channel.h | 4 ++++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 41cc7c8..af53804 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ docs .vscode -.DS_Store \ No newline at end of file +.DS_Store +build/* \ No newline at end of file diff --git a/README.md b/README.md index f244487..569aeed 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,8 @@ void UpdateDisplay() { } ``` +### Build for release + +``` +$ arduino-cli compile -v -b arduino:avr:nano ./firmware/Gravity/Gravity.ino -e --output-dir=./build/ +``` \ No newline at end of file diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index 34de653..374ee03 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -125,13 +125,13 @@ void HandleIntClockTick(uint32_t tick) { int clock_index; switch (app.selected_pulse) { case Clock::PULSE_PPQN_24: - clock_index = 0; + clock_index = PULSE_PPQN_24_CLOCK_MOD_INDEX; break; case Clock::PULSE_PPQN_4: - clock_index = 4; + clock_index = PULSE_PPQN_4_CLOCK_MOD_INDEX; break; case Clock::PULSE_PPQN_1: - clock_index = 7; + clock_index = PULSE_PPQN_1_CLOCK_MOD_INDEX; break; } diff --git a/firmware/Gravity/channel.h b/firmware/Gravity/channel.h index 6f46b28..8036781 100644 --- a/firmware/Gravity/channel.h +++ b/firmware/Gravity/channel.h @@ -53,6 +53,10 @@ static const int CLOCK_MOD_PULSES[MOD_CHOICE_SIZE] PROGMEM = { static const byte DEFAULT_CLOCK_MOD_INDEX = 8; // x1 or 96 PPQN. +static const byte PULSE_PPQN_24_CLOCK_MOD_INDEX = 0; +static const byte PULSE_PPQN_4_CLOCK_MOD_INDEX = 4; +static const byte PULSE_PPQN_1_CLOCK_MOD_INDEX = 8; + class Channel { public: Channel() { -- 2.39.5 From 5729eef037d31981e1829a2d436899670540594a Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Mon, 21 Jul 2025 00:00:47 +0000 Subject: [PATCH 47/69] Factory Reset (#13) Fixes https://github.com/awonak/alt-gravity/issues/1 Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/13 --- firmware/Gravity/Gravity.ino | 9 ++++++++ firmware/Gravity/app_state.h | 24 -------------------- firmware/Gravity/display.h | 39 ++++++++++++++++++++++++++++++++- firmware/Gravity/save_state.cpp | 12 +++++++++- firmware/Gravity/save_state.h | 6 +++-- 5 files changed, 62 insertions(+), 28 deletions(-) diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index 374ee03..06934f9 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -203,6 +203,12 @@ void HandleEncoderPressed() { InitGravity(app); } } + if (app.selected_param == PARAM_MAIN_FACTORY_RESET) { + if (app.selected_sub_param == 0) { // Reset + stateManager.factoryReset(); + InitGravity(app); + } + } } // Only mark dirty and reset selected_sub_param when leaving editing mode. stateManager.markDirty(); @@ -277,6 +283,9 @@ void editMainParameter(int val) { case PARAM_MAIN_RESET_STATE: updateSelection(app.selected_sub_param, val, 2); break; + case PARAM_MAIN_FACTORY_RESET: + updateSelection(app.selected_sub_param, val, 2); + break; } } diff --git a/firmware/Gravity/app_state.h b/firmware/Gravity/app_state.h index e7d9ab5..0ddd014 100644 --- a/firmware/Gravity/app_state.h +++ b/firmware/Gravity/app_state.h @@ -38,28 +38,4 @@ static Channel& GetSelectedChannel() { return app.channel[app.selected_channel - 1]; } -enum ParamsMainPage : uint8_t { - PARAM_MAIN_TEMPO, - PARAM_MAIN_SOURCE, - PARAM_MAIN_PULSE, - PARAM_MAIN_ENCODER_DIR, - PARAM_MAIN_SAVE_DATA, - PARAM_MAIN_LOAD_DATA, - PARAM_MAIN_RESET_STATE, - PARAM_MAIN_LAST, -}; - -enum ParamsChannelPage : uint8_t { - PARAM_CH_MOD, - PARAM_CH_PROB, - PARAM_CH_DUTY, - PARAM_CH_OFFSET, - PARAM_CH_SWING, - PARAM_CH_EUC_STEPS, - PARAM_CH_EUC_HITS, - PARAM_CH_CV1_DEST, - PARAM_CH_CV2_DEST, - PARAM_CH_LAST, -}; - #endif // APP_STATE_H \ No newline at end of file diff --git a/firmware/Gravity/display.h b/firmware/Gravity/display.h index cf37631..1823b75 100644 --- a/firmware/Gravity/display.h +++ b/firmware/Gravity/display.h @@ -96,6 +96,33 @@ constexpr uint8_t CHANNEL_BOXES_Y = 50; constexpr uint8_t CHANNEL_BOX_WIDTH = 18; constexpr uint8_t CHANNEL_BOX_HEIGHT = 14; +// Menu items for editing global parameters. +enum ParamsMainPage : uint8_t { + PARAM_MAIN_TEMPO, + PARAM_MAIN_SOURCE, + PARAM_MAIN_PULSE, + PARAM_MAIN_ENCODER_DIR, + PARAM_MAIN_SAVE_DATA, + PARAM_MAIN_LOAD_DATA, + PARAM_MAIN_RESET_STATE, + PARAM_MAIN_FACTORY_RESET, + PARAM_MAIN_LAST, +}; + +// Menu items for editing channel parameters. +enum ParamsChannelPage : uint8_t { + PARAM_CH_MOD, + PARAM_CH_PROB, + PARAM_CH_DUTY, + PARAM_CH_OFFSET, + PARAM_CH_SWING, + PARAM_CH_EUC_STEPS, + PARAM_CH_EUC_HITS, + PARAM_CH_CV1_DEST, + PARAM_CH_CV2_DEST, + PARAM_CH_LAST, +}; + // Helper function to draw centered text void drawCenteredText(const char* text, int y, const uint8_t* font) { gravity.display.setFont(font); @@ -278,13 +305,23 @@ void DisplayMainPage() { mainText = F("x"); subText = F("BACK TO MAIN"); } + break; + case PARAM_MAIN_FACTORY_RESET: + if (app.selected_sub_param == 0) { + mainText = F("DEL"); + subText = F("FACTORY RESET"); + } else { + mainText = F("x"); + subText = F("BACK TO MAIN"); + } + break; } drawCenteredText(mainText.c_str(), MAIN_TEXT_Y, LARGE_FONT); drawCenteredText(subText.c_str(), SUB_TEXT_Y, TEXT_FONT); // Draw Main Page menu items - String menu_items[PARAM_MAIN_LAST] = {F("TEMPO"), F("SOURCE"), F("PULSE OUT"), F("ENCODER DIR"), F("SAVE"), F("LOAD"), F("RESET")}; + String menu_items[PARAM_MAIN_LAST] = {F("TEMPO"), F("SOURCE"), F("PULSE OUT"), F("ENCODER DIR"), F("SAVE"), F("LOAD"), F("RESET"), F("ERASE")}; drawMenuItems(menu_items, PARAM_MAIN_LAST); } diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index 052779b..16ef342 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -26,7 +26,8 @@ bool StateManager::initialize(AppState& app) { return loadData(app, MAX_SAVE_SLOTS); } else { // EEPROM does not contain save data for this firmware & version. - // Initialize eeprom and save default patter to all save slots. + // Erase EEPROM and initialize state. Save default pattern to all save slots. + factoryReset(); reset(app); _saveMetadata(); // MAX_SAVE_SLOTS slot is reserved for transient state. @@ -82,6 +83,15 @@ void StateManager::markDirty() { _lastChangeTime = millis(); } +// Erases all data in the EEPROM by writing 0 to every address. +void StateManager::factoryReset() { + noInterrupts(); + for (unsigned int i = 0 ; i < EEPROM.length() ; i++) { + EEPROM.write(i, 0); + } + interrupts(); +} + bool StateManager::_isDataValid() { Metadata load_meta; EEPROM.get(0, load_meta); diff --git a/firmware/Gravity/save_state.h b/firmware/Gravity/save_state.h index 354253a..29ae845 100644 --- a/firmware/Gravity/save_state.h +++ b/firmware/Gravity/save_state.h @@ -19,8 +19,8 @@ struct AppState; // Define the constants for the current firmware. -const char SKETCH_NAME[] = "Gravity"; -const byte SKETCH_VERSION = 7; +const char SKETCH_NAME[] = "AltGravity"; +const byte SKETCH_VERSION = 1; // Number of available save slots. const byte MAX_SAVE_SLOTS = 10; @@ -52,6 +52,8 @@ class StateManager { void update(const AppState& app); // Indicate that state has changed and we should save. void markDirty(); + // Erase all data stored in the EEPROM. + void factoryReset(); // This struct holds the data that identifies the firmware version. struct Metadata { -- 2.39.5 From 1bf90e16742d6e789534bf6e5dc12e0e7d5e76e4 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Mon, 21 Jul 2025 00:01:18 +0000 Subject: [PATCH 48/69] Mute channel when shift + play pressed (#14) Fixes https://github.com/awonak/alt-gravity/issues/2 Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/14 --- firmware/Gravity/Gravity.ino | 25 ++++++++++++++++++++----- firmware/Gravity/channel.h | 11 +++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index 06934f9..b76a61b 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -2,7 +2,7 @@ * @file Gravity.ino * @author Adam Wonak (https://github.com/awonak/) * @brief Alt firmware version of Gravity by Sitka Instruments. - * @version v2.0.1 - June 2025 awonak - Full rewrite + * @version v2.0.1 - June 2025 awonak - Full rewrite * @version v1.0 - August 2023 Oleksiy H - Initial release * @date 2025-07-04 * @@ -25,7 +25,7 @@ * quantization of features like duty cycle (pulse width) or offset. * Additionally, this firmware replaces the sequencer with a Euclidean Rhythm * generator. - * + * * ENCODER: * Press: change between selecting a parameter and editing the parameter. * Hold & Rotate: change current selected output channel. @@ -33,17 +33,17 @@ * BTN1: * Play/pause - start or stop the internal clock. * - * BTN2: + * BTN2: * Shift - hold and rotate encoder to change current selected output channel. * * EXT: * External clock input. When Gravity is set to INTERNAL clock mode, this * input is used to reset clocks. - * + * * CV1: * CV2: * External analog input used to provide modulation to any channel parameter. - * + * */ #include @@ -168,6 +168,21 @@ void HandleExtClockTick() { // void HandlePlayPressed() { + // Check if SHIFT is pressed to mute all/current channel. + if (gravity.shift_button.On()) { + if (app.selected_channel == 0) { + // Mute all channels + for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { + app.channel[i].toggleMute(); + } + } else { + // Mute selected channel + auto& ch = GetSelectedChannel(); + ch.toggleMute(); + } + return; + } + gravity.clock.IsPaused() ? gravity.clock.Start() : gravity.clock.Stop(); diff --git a/firmware/Gravity/channel.h b/firmware/Gravity/channel.h index 8036781..8bcf999 100644 --- a/firmware/Gravity/channel.h +++ b/firmware/Gravity/channel.h @@ -161,6 +161,8 @@ class Channel { byte getSteps(bool withCvMod = false) const { return withCvMod ? pattern.GetSteps() : base_euc_steps; } byte getHits(bool withCvMod = false) const { return withCvMod ? pattern.GetHits() : base_euc_hits; } + void toggleMute() { mute = !mute; } + /** * @brief Processes a clock tick and determines if the output should be high or low. * Note: this method is called from an ISR and must be kept as simple as possible. @@ -168,6 +170,12 @@ class Channel { * @param output The output object to be modified. */ void processClockTick(uint32_t tick, DigitalOutput& output) { + // Mute check + if (mute) { + output.Low(); + return; + } + const uint16_t mod_pulses = pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]); // Conditionally apply swing on down beats. @@ -298,6 +306,9 @@ class Channel { // Euclidean pattern Pattern pattern; + // Mute channel flag + bool mute; + // Pre-calculated pulse values for ISR performance uint16_t _duty_pulses; uint16_t _offset_pulses; -- 2.39.5 From 4f04137f67059f4b2f58f4bbb4dfb5150a7768c8 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Mon, 21 Jul 2025 00:27:32 +0000 Subject: [PATCH 49/69] Add global/hardware settings to metadata EEPROM (#15) Settings like Encoder Direction and Display Orientation should persist when resetting channel state. Fixes https://github.com/awonak/alt-gravity/issues/7 Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/15 --- firmware/Gravity/Gravity.ino | 4 ++-- firmware/Gravity/save_state.cpp | 42 ++++++++++++++++++++++++++------- firmware/Gravity/save_state.h | 5 +++- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index b76a61b..3b388d4 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -196,8 +196,8 @@ void HandleEncoderPressed() { if (app.selected_channel == 0) { // main page // TODO: rewrite as switch if (app.selected_param == PARAM_MAIN_ENCODER_DIR) { - bool reversed = app.selected_sub_param == 1; - gravity.encoder.SetReverseDirection(reversed); + app.encoder_reversed = app.selected_sub_param == 1; + gravity.encoder.SetReverseDirection(app.encoder_reversed); } if (app.selected_param == PARAM_MAIN_SAVE_DATA) { if (app.selected_sub_param < MAX_SAVE_SLOTS) { diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index 16ef342..8aa1f50 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -16,6 +16,7 @@ #include "app_state.h" // Calculate the starting address for EepromData, leaving space for metadata. +static const int METADATA_START_ADDR = 0; static const int EEPROM_DATA_START_ADDR = sizeof(StateManager::Metadata); StateManager::StateManager() : _isDirty(false), _lastChangeTime(0) {} @@ -24,12 +25,14 @@ bool StateManager::initialize(AppState& app) { if (_isDataValid()) { // Load data from the transient slot. return loadData(app, MAX_SAVE_SLOTS); - } else { - // EEPROM does not contain save data for this firmware & version. + } + // EEPROM does not contain save data for this firmware & version. + else { // Erase EEPROM and initialize state. Save default pattern to all save slots. factoryReset(); + // Initialize eeprom and save default patter to all save slots. + _saveMetadata(app); reset(app); - _saveMetadata(); // MAX_SAVE_SLOTS slot is reserved for transient state. for (int i = 0; i <= MAX_SAVE_SLOTS; i++) { app.selected_save_slot = i; @@ -43,10 +46,12 @@ bool StateManager::loadData(AppState& app, byte slot_index) { if (slot_index >= MAX_SAVE_SLOTS) return false; _loadState(app, slot_index); + _loadMetadata(app); return true; } +// Save app state to user specified save slot. void StateManager::saveData(const AppState& app) { if (app.selected_save_slot >= MAX_SAVE_SLOTS) return; @@ -54,17 +59,18 @@ void StateManager::saveData(const AppState& app) { _isDirty = false; } +// Save transient state if it has changed and enough time has passed since last save. void StateManager::update(const AppState& app) { if (_isDirty && (millis() - _lastChangeTime > SAVE_DELAY_MS)) { // MAX_SAVE_SLOTS slot is reserved for transient state. _saveState(app, MAX_SAVE_SLOTS); + _saveMetadata(app); _isDirty = false; } } void StateManager::reset(AppState& app) { app.tempo = Clock::DEFAULT_TEMPO; - app.encoder_reversed = false; app.selected_param = 0; app.selected_channel = 0; app.selected_source = Clock::SOURCE_INTERNAL; @@ -75,6 +81,9 @@ void StateManager::reset(AppState& app) { app.channel[i].Init(); } + // Load global settings from Metadata + _loadMetadata(app); + _isDirty = false; } @@ -86,7 +95,7 @@ void StateManager::markDirty() { // Erases all data in the EEPROM by writing 0 to every address. void StateManager::factoryReset() { noInterrupts(); - for (unsigned int i = 0 ; i < EEPROM.length() ; i++) { + for (unsigned int i = 0; i < EEPROM.length(); i++) { EEPROM.write(i, 0); } interrupts(); @@ -94,7 +103,7 @@ void StateManager::factoryReset() { bool StateManager::_isDataValid() { Metadata load_meta; - EEPROM.get(0, load_meta); + EEPROM.get(METADATA_START_ADDR, load_meta); bool name_match = (strcmp(load_meta.sketch_name, SKETCH_NAME) == 0); bool version_match = (load_meta.version == SKETCH_VERSION); return name_match && version_match; @@ -114,6 +123,10 @@ void StateManager::_saveState(const AppState& app, byte slot_index) { save_data.selected_pulse = static_cast(app.selected_pulse); save_data.selected_save_slot = app.selected_save_slot; + // TODO: break this out into a separate function. Save State should be + // broken out into global / per-channel save methods. When saving via + // "update" only save state for the current channel since other channels + // will not have changed when saving user edits. for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { const auto& ch = app.channel[i]; auto& save_ch = save_data.channel_data[i]; @@ -141,7 +154,6 @@ void StateManager::_loadState(AppState& app, byte slot_index) { // Restore app state from loaded data. app.tempo = load_data.tempo; - app.encoder_reversed = load_data.encoder_reversed; app.selected_param = load_data.selected_param; app.selected_channel = load_data.selected_channel; app.selected_source = static_cast(load_data.selected_source); @@ -165,11 +177,23 @@ void StateManager::_loadState(AppState& app, byte slot_index) { interrupts(); } -void StateManager::_saveMetadata() { +void StateManager::_saveMetadata(const AppState& app) { noInterrupts(); Metadata current_meta; strcpy(current_meta.sketch_name, SKETCH_NAME); current_meta.version = SKETCH_VERSION; - EEPROM.put(0, current_meta); + + // Global user settings + 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.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 29ae845..bda7c4f 100644 --- a/firmware/Gravity/save_state.h +++ b/firmware/Gravity/save_state.h @@ -59,6 +59,8 @@ class StateManager { struct Metadata { byte version; char sketch_name[16]; + // Additional global/hardware settings + bool encoder_reversed; }; struct ChannelState { byte base_clock_mod_index; @@ -85,7 +87,8 @@ class StateManager { private: bool _isDataValid(); - void _saveMetadata(); + void _saveMetadata(const AppState& app); + void _loadMetadata(AppState& app); void _saveState(const AppState& app, byte slot_index); void _loadState(AppState& app, byte slot_index); -- 2.39.5 From 01f32407f6594405a6c3539194bad655be179b30 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sun, 20 Jul 2025 17:53:03 -0700 Subject: [PATCH 50/69] bump version --- firmware/Gravity/save_state.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firmware/Gravity/save_state.h b/firmware/Gravity/save_state.h index 91e1e8f..659a6e6 100644 --- a/firmware/Gravity/save_state.h +++ b/firmware/Gravity/save_state.h @@ -20,7 +20,7 @@ struct AppState; // Define the constants for the current firmware. const char SKETCH_NAME[] = "ALT GRAVITY"; -const char SEMANTIC_VERSION[] = "V2.0.0BETA1"; +const char SEMANTIC_VERSION[] = "V2.0.0BETA2"; // Number of available save slots. const byte MAX_SAVE_SLOTS = 10; // Count of save slots 0 - 9 to save/load presets. -- 2.39.5 From 1c0fb86bc102c303907de39642d5ca2bfaf1e88d Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Tue, 22 Jul 2025 00:00:49 +0000 Subject: [PATCH 51/69] Reverse the order of clock mod options. (#16) This now matches original Gravity behavior. Also, now when applying CV mod positive voltages increase clock mod instead of reducing it. Also fix pulse out, which wasn't previously updated when CLOCK_MOD was moved to program mem. Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/16 --- clock.h | 3 --- firmware/Gravity/Gravity.ino | 5 ++--- firmware/Gravity/channel.h | 26 +++++++++++++------------- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/clock.h b/clock.h index 9cb2ad8..4008e0d 100644 --- a/clock.h +++ b/clock.h @@ -50,9 +50,6 @@ class Clock { void Init() { NeoSerial.begin(31250); - // Static pin definition for pulse out. - pinMode(PULSE_OUT_PIN, OUTPUT); - // Initialize the clock library uClock.init(); uClock.setClockMode(uClock.INTERNAL_CLOCK); diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index 3b388d4..c0f9481 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -135,13 +135,12 @@ void HandleIntClockTick(uint32_t tick) { break; } - const uint32_t pulse_high_ticks = CLOCK_MOD_PULSES[clock_index]; + const uint16_t pulse_high_ticks = pgm_read_word_near(&CLOCK_MOD_PULSES[clock_index]); const uint32_t pulse_low_ticks = tick + max((pulse_high_ticks / 2), 1L); if (tick % pulse_high_ticks == 0) { gravity.pulse.High(); - } - if (pulse_low_ticks % pulse_high_ticks == 0) { + } else if (pulse_low_ticks % pulse_high_ticks == 0) { gravity.pulse.Low(); } } diff --git a/firmware/Gravity/channel.h b/firmware/Gravity/channel.h index 8bcf999..f30b4c8 100644 --- a/firmware/Gravity/channel.h +++ b/firmware/Gravity/channel.h @@ -34,28 +34,28 @@ static const byte MOD_CHOICE_SIZE = 25; // Negative numbers are multipliers, positive are divisors. static const int CLOCK_MOD[MOD_CHOICE_SIZE] PROGMEM = { - // Multipliers - -24, -16, -12, -8, -6, -4, -3, -2, - // Internal Clock Unity - 1, // Divisors - 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 24, 32, 64, 128}; + 128, 64, 32, 24, 16, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, + // Internal Clock Unity (quarter note) + 1, + // Multipliers + -2, -3, -4, -6, -8, -12, -16, -24}; // This represents the number of clock pulses for a 96 PPQN clock source // that match the above div/mult mods. static const int CLOCK_MOD_PULSES[MOD_CHOICE_SIZE] PROGMEM = { - // Multiplier Pulses (96 / X) - 4, 6, 8, 12, 16, 24, 32, 48, + // Divisor Pulses (96 * X) + 12288, 6144, 3072, 2304, 1536, 1152, 1056, 960, 864, 768, 672, 576, 480, 384, 288, 192, // Internal Clock Pulses 96, - // Divisor Pulses (96 * X) - 192, 288, 384, 480, 576, 672, 768, 864, 960, 1056, 1152, 1536, 2304, 3072, 6144, 12288}; + // Multiplier Pulses (96 / X) + 48, 32, 24, 16, 12, 8, 6, 4}; -static const byte DEFAULT_CLOCK_MOD_INDEX = 8; // x1 or 96 PPQN. +static const byte DEFAULT_CLOCK_MOD_INDEX = 16; // x1 or 96 PPQN. -static const byte PULSE_PPQN_24_CLOCK_MOD_INDEX = 0; -static const byte PULSE_PPQN_4_CLOCK_MOD_INDEX = 4; -static const byte PULSE_PPQN_1_CLOCK_MOD_INDEX = 8; +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: -- 2.39.5 From b0accdc83a6625b7a8c9333c9e174b98d3653559 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Tue, 22 Jul 2025 05:12:45 +0000 Subject: [PATCH 52/69] Fix Initial Transient State (#17) There was an off-by-one error that was not properly loading transient state from the designated memory slot. Also fixes setting the last saved/loaded slot indicator with metadata. Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/17 --- firmware/Gravity/save_state.cpp | 25 +++++++++++++++---------- firmware/Gravity/save_state.h | 5 +++-- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index 8aa1f50..08c811b 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -24,7 +24,7 @@ StateManager::StateManager() : _isDirty(false), _lastChangeTime(0) {} bool StateManager::initialize(AppState& app) { if (_isDataValid()) { // Load data from the transient slot. - return loadData(app, MAX_SAVE_SLOTS); + return loadData(app, TRANSIENT_SLOT); } // EEPROM does not contain save data for this firmware & version. else { @@ -33,17 +33,17 @@ bool StateManager::initialize(AppState& app) { // Initialize eeprom and save default patter to all save slots. _saveMetadata(app); reset(app); - // MAX_SAVE_SLOTS slot is reserved for transient state. - for (int i = 0; i <= MAX_SAVE_SLOTS; i++) { - app.selected_save_slot = i; + for (int i = 0; i < MAX_SAVE_SLOTS; i++) { _saveState(app, i); } + _saveState(app, TRANSIENT_SLOT); return false; } } bool StateManager::loadData(AppState& app, byte slot_index) { - if (slot_index >= MAX_SAVE_SLOTS) return false; + // Check if slot_index is within max range + 1 for transient. + if (slot_index >= MAX_SAVE_SLOTS + 1) return false; _loadState(app, slot_index); _loadMetadata(app); @@ -53,7 +53,8 @@ bool StateManager::loadData(AppState& app, byte slot_index) { // Save app state to user specified save slot. void StateManager::saveData(const AppState& app) { - if (app.selected_save_slot >= MAX_SAVE_SLOTS) return; + // Check if slot_index is within max range + 1 for transient. + if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) return; _saveState(app, app.selected_save_slot); _isDirty = false; @@ -62,8 +63,7 @@ void StateManager::saveData(const AppState& app) { // Save transient state if it has changed and enough time has passed since last save. void StateManager::update(const AppState& app) { if (_isDirty && (millis() - _lastChangeTime > SAVE_DELAY_MS)) { - // MAX_SAVE_SLOTS slot is reserved for transient state. - _saveState(app, MAX_SAVE_SLOTS); + _saveState(app, TRANSIENT_SLOT); _saveMetadata(app); _isDirty = false; } @@ -110,7 +110,8 @@ bool StateManager::_isDataValid() { } void StateManager::_saveState(const AppState& app, byte slot_index) { - if (app.selected_save_slot >= MAX_SAVE_SLOTS) return; + // Check if slot_index is within max range + 1 for transient. + if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) return; noInterrupts(); static EepromData save_data; @@ -121,7 +122,6 @@ 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.selected_save_slot = app.selected_save_slot; // TODO: break this out into a separate function. Save State should be // broken out into global / per-channel save methods. When saving via @@ -147,6 +147,9 @@ void StateManager::_saveState(const AppState& app, byte slot_index) { } void StateManager::_loadState(AppState& app, byte slot_index) { + // Check if slot_index is within max range + 1 for transient. + if (slot_index >= MAX_SAVE_SLOTS + 1) return; + noInterrupts(); static EepromData load_data; int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData)); @@ -184,6 +187,7 @@ void StateManager::_saveMetadata(const AppState& app) { current_meta.version = SKETCH_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); @@ -194,6 +198,7 @@ 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 bda7c4f..ec75f03 100644 --- a/firmware/Gravity/save_state.h +++ b/firmware/Gravity/save_state.h @@ -23,7 +23,8 @@ const char SKETCH_NAME[] = "AltGravity"; const byte SKETCH_VERSION = 1; // Number of available save slots. -const byte MAX_SAVE_SLOTS = 10; +const byte MAX_SAVE_SLOTS = 10; // Count of save slots 0 - 9 to save/load presets. +const byte TRANSIENT_SLOT = 10; // Transient slot index to persist state when powered off. // Define the minimum amount of time between EEPROM writes. static const unsigned long SAVE_DELAY_MS = 2000; @@ -60,6 +61,7 @@ class StateManager { byte version; char sketch_name[16]; // Additional global/hardware settings + byte selected_save_slot; bool encoder_reversed; }; struct ChannelState { @@ -81,7 +83,6 @@ class StateManager { byte selected_channel; byte selected_source; byte selected_pulse; - byte selected_save_slot; ChannelState channel_data[Gravity::OUTPUT_COUNT]; }; -- 2.39.5 From c5bddef66d0ddd4017339ddd26862e79e25c9d1c Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Tue, 22 Jul 2025 05:16:32 +0000 Subject: [PATCH 53/69] Show loading bootsplash with firmware name and version (#18) Bootsplash is displayed before EEPROM erase, which is a slow operation. Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/18 --- firmware/Gravity/Gravity.ino | 6 ++++++ firmware/Gravity/display.h | 17 +++++++++++++++++ firmware/Gravity/save_state.cpp | 4 ++-- firmware/Gravity/save_state.h | 6 +++--- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index c0f9481..e58e8c5 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -64,6 +64,10 @@ void setup() { // Start Gravity. gravity.Init(); + // Show bootsplash when initializing firmware. + Bootsplash(); + delay(2000); + // Initialize the state manager. This will load settings from EEPROM stateManager.initialize(app); InitGravity(app); @@ -219,7 +223,9 @@ void HandleEncoderPressed() { } if (app.selected_param == PARAM_MAIN_FACTORY_RESET) { if (app.selected_sub_param == 0) { // Reset + Bootsplash(); stateManager.factoryReset(); + stateManager.reset(app); InitGravity(app); } } diff --git a/firmware/Gravity/display.h b/firmware/Gravity/display.h index 1823b75..39ad8fd 100644 --- a/firmware/Gravity/display.h +++ b/firmware/Gravity/display.h @@ -469,4 +469,21 @@ void UpdateDisplay() { } while (gravity.display.nextPage()); } +void Bootsplash() { + gravity.display.firstPage(); + do { + int textWidth; + gravity.display.setFont(TEXT_FONT); + + textWidth = gravity.display.getStrWidth(SKETCH_NAME); + gravity.display.drawStr(24 + (textWidth / 2), 24, SKETCH_NAME); + + textWidth = gravity.display.getStrWidth(SEMANTIC_VERSION); + gravity.display.drawStr(24 + (textWidth / 2), 36, SEMANTIC_VERSION); + + textWidth = gravity.display.getStrWidth("LOADING...."); + gravity.display.drawStr(34 + (textWidth / 2), 48, "LOADING...."); + } while (gravity.display.nextPage()); +} + #endif // DISPLAY_H diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index 08c811b..3b29239 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -105,7 +105,7 @@ bool StateManager::_isDataValid() { Metadata load_meta; EEPROM.get(METADATA_START_ADDR, load_meta); bool name_match = (strcmp(load_meta.sketch_name, SKETCH_NAME) == 0); - bool version_match = (load_meta.version == SKETCH_VERSION); + bool version_match = (strcmp(load_meta.version, SEMANTIC_VERSION) == 0); return name_match && version_match; } @@ -184,7 +184,7 @@ void StateManager::_saveMetadata(const AppState& app) { noInterrupts(); Metadata current_meta; strcpy(current_meta.sketch_name, SKETCH_NAME); - current_meta.version = SKETCH_VERSION; + strcpy(current_meta.version, SEMANTIC_VERSION); // Global user settings current_meta.selected_save_slot = app.selected_save_slot; diff --git a/firmware/Gravity/save_state.h b/firmware/Gravity/save_state.h index ec75f03..91e1e8f 100644 --- a/firmware/Gravity/save_state.h +++ b/firmware/Gravity/save_state.h @@ -19,8 +19,8 @@ struct AppState; // Define the constants for the current firmware. -const char SKETCH_NAME[] = "AltGravity"; -const byte SKETCH_VERSION = 1; +const char SKETCH_NAME[] = "ALT GRAVITY"; +const char SEMANTIC_VERSION[] = "V2.0.0BETA1"; // Number of available save slots. const byte MAX_SAVE_SLOTS = 10; // Count of save slots 0 - 9 to save/load presets. @@ -58,8 +58,8 @@ class StateManager { // This struct holds the data that identifies the firmware version. struct Metadata { - byte version; char sketch_name[16]; + char version[16]; // Additional global/hardware settings byte selected_save_slot; bool encoder_reversed; -- 2.39.5 From ec34bc3a7bba67d9da002c1b6228e778a3213114 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Wed, 23 Jul 2025 03:32:16 +0000 Subject: [PATCH 54/69] Fix metadata loading issues with Initialization and refactor Factory Reset. (#19) Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/19 --- firmware/Gravity/Gravity.ino | 5 ++- firmware/Gravity/display.h | 6 ++-- firmware/Gravity/save_state.cpp | 54 ++++++++++++++++++--------------- firmware/Gravity/save_state.h | 5 ++- 4 files changed, 37 insertions(+), 33 deletions(-) diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index e58e8c5..122cbf2 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -222,10 +222,9 @@ void HandleEncoderPressed() { } } if (app.selected_param == PARAM_MAIN_FACTORY_RESET) { - if (app.selected_sub_param == 0) { // Reset + if (app.selected_sub_param == 0) { // Erase Bootsplash(); - stateManager.factoryReset(); - stateManager.reset(app); + stateManager.factoryReset(app); InitGravity(app); } } diff --git a/firmware/Gravity/display.h b/firmware/Gravity/display.h index 39ad8fd..dc67447 100644 --- a/firmware/Gravity/display.h +++ b/firmware/Gravity/display.h @@ -476,13 +476,13 @@ void Bootsplash() { gravity.display.setFont(TEXT_FONT); textWidth = gravity.display.getStrWidth(SKETCH_NAME); - gravity.display.drawStr(24 + (textWidth / 2), 24, SKETCH_NAME); + gravity.display.drawStr(16 + (textWidth / 2), 20, SKETCH_NAME); textWidth = gravity.display.getStrWidth(SEMANTIC_VERSION); - gravity.display.drawStr(24 + (textWidth / 2), 36, SEMANTIC_VERSION); + gravity.display.drawStr(16 + (textWidth / 2), 32, SEMANTIC_VERSION); textWidth = gravity.display.getStrWidth("LOADING...."); - gravity.display.drawStr(34 + (textWidth / 2), 48, "LOADING...."); + gravity.display.drawStr(26 + (textWidth / 2), 44, "LOADING...."); } while (gravity.display.nextPage()); } diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index 3b29239..674115f 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -23,20 +23,16 @@ StateManager::StateManager() : _isDirty(false), _lastChangeTime(0) {} bool StateManager::initialize(AppState& app) { if (_isDataValid()) { - // Load data from the transient slot. - return loadData(app, TRANSIENT_SLOT); + // 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(); - // Initialize eeprom and save default patter to all save slots. - _saveMetadata(app); - reset(app); - for (int i = 0; i < MAX_SAVE_SLOTS; i++) { - _saveState(app, i); - } - _saveState(app, TRANSIENT_SLOT); + factoryReset(app); return false; } } @@ -45,8 +41,11 @@ 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); - _loadMetadata(app); + app.selected_save_slot = slot_index; + // Persist this change in the global metadata. + _saveMetadata(app); return true; } @@ -57,6 +56,7 @@ void StateManager::saveData(const AppState& app) { if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) return; _saveState(app, app.selected_save_slot); + _saveMetadata(app); _isDirty = false; } @@ -70,12 +70,12 @@ void StateManager::update(const AppState& app) { } void StateManager::reset(AppState& app) { - app.tempo = Clock::DEFAULT_TEMPO; - app.selected_param = 0; - app.selected_channel = 0; - app.selected_source = Clock::SOURCE_INTERNAL; - app.selected_pulse = Clock::PULSE_PPQN_24; - app.selected_save_slot = 0; + AppState default_app; + app.tempo = default_app.tempo; + app.selected_param = default_app.selected_param; + app.selected_channel = default_app.selected_channel; + app.selected_source = default_app.selected_source; + app.selected_pulse = default_app.selected_pulse; for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { app.channel[i].Init(); @@ -93,19 +93,27 @@ void StateManager::markDirty() { } // Erases all data in the EEPROM by writing 0 to every address. -void StateManager::factoryReset() { +void StateManager::factoryReset(AppState& app) { noInterrupts(); for (unsigned int i = 0; i < EEPROM.length(); i++) { EEPROM.write(i, 0); } + // Initialize eeprom and save default patter to all save slots. + _saveMetadata(app); + reset(app); + for (int i = 0; i < MAX_SAVE_SLOTS; i++) { + app.selected_save_slot = i; + _saveState(app, i); + } + _saveState(app, TRANSIENT_SLOT); interrupts(); } bool StateManager::_isDataValid() { - Metadata load_meta; - EEPROM.get(METADATA_START_ADDR, load_meta); - bool name_match = (strcmp(load_meta.sketch_name, SKETCH_NAME) == 0); - bool version_match = (strcmp(load_meta.version, SEMANTIC_VERSION) == 0); + 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; } @@ -117,7 +125,6 @@ void StateManager::_saveState(const AppState& app, byte slot_index) { static EepromData save_data; save_data.tempo = app.tempo; - save_data.encoder_reversed = app.encoder_reversed; save_data.selected_param = app.selected_param; save_data.selected_channel = app.selected_channel; save_data.selected_source = static_cast(app.selected_source); @@ -161,7 +168,6 @@ 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.selected_save_slot = slot_index; 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 91e1e8f..cd7fcba 100644 --- a/firmware/Gravity/save_state.h +++ b/firmware/Gravity/save_state.h @@ -20,7 +20,7 @@ struct AppState; // Define the constants for the current firmware. const char SKETCH_NAME[] = "ALT GRAVITY"; -const char SEMANTIC_VERSION[] = "V2.0.0BETA1"; +const char SEMANTIC_VERSION[] = "V2.0.0BETA2"; // Number of available save slots. const byte MAX_SAVE_SLOTS = 10; // Count of save slots 0 - 9 to save/load presets. @@ -54,7 +54,7 @@ class StateManager { // Indicate that state has changed and we should save. void markDirty(); // Erase all data stored in the EEPROM. - void factoryReset(); + void factoryReset(AppState& app); // This struct holds the data that identifies the firmware version. struct Metadata { @@ -78,7 +78,6 @@ class StateManager { // This struct holds all the parameters we want to save. struct EepromData { int tempo; - bool encoder_reversed; byte selected_param; byte selected_channel; byte selected_source; -- 2.39.5 From c7a3277b5fc9e2ae10987f3f972acb498cc9256f Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Thu, 24 Jul 2025 07:53:41 -0700 Subject: [PATCH 55/69] Memory improvements in bootsplash and StateManager --- firmware/Gravity/Gravity.ino | 7 ++++--- firmware/Gravity/app_state.h | 8 ++++---- firmware/Gravity/display.h | 23 ++++++++++++----------- firmware/Gravity/save_state.cpp | 15 +++++++++++++-- firmware/Gravity/save_state.h | 20 +++++++++----------- 5 files changed, 42 insertions(+), 31 deletions(-) diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index 122cbf2..18c784e 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -203,13 +203,13 @@ void HandleEncoderPressed() { gravity.encoder.SetReverseDirection(app.encoder_reversed); } if (app.selected_param == PARAM_MAIN_SAVE_DATA) { - if (app.selected_sub_param < MAX_SAVE_SLOTS) { + if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) { app.selected_save_slot = app.selected_sub_param; stateManager.saveData(app); } } if (app.selected_param == PARAM_MAIN_LOAD_DATA) { - if (app.selected_sub_param < MAX_SAVE_SLOTS) { + if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) { app.selected_save_slot = app.selected_sub_param; stateManager.loadData(app, app.selected_save_slot); InitGravity(app); @@ -223,6 +223,7 @@ void HandleEncoderPressed() { } 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); @@ -297,7 +298,7 @@ void editMainParameter(int val) { break; case PARAM_MAIN_SAVE_DATA: case PARAM_MAIN_LOAD_DATA: - updateSelection(app.selected_sub_param, val, MAX_SAVE_SLOTS + 1); + updateSelection(app.selected_sub_param, val, StateManager::MAX_SAVE_SLOTS + 1); break; case PARAM_MAIN_RESET_STATE: updateSelection(app.selected_sub_param, val, 2); diff --git a/firmware/Gravity/app_state.h b/firmware/Gravity/app_state.h index 0ddd014..b53d314 100644 --- a/firmware/Gravity/app_state.h +++ b/firmware/Gravity/app_state.h @@ -19,9 +19,7 @@ // Global state for settings and app behavior. struct AppState { int tempo = Clock::DEFAULT_TEMPO; - bool encoder_reversed = false; - bool refresh_screen = true; - bool editing_param = false; + Channel channel[Gravity::OUTPUT_COUNT]; byte selected_param = 0; byte selected_sub_param = 0; // Temporary value for editing params. byte selected_channel = 0; // 0=tempo, 1-6=output channel @@ -29,7 +27,9 @@ struct AppState { byte selected_save_slot = 0; // The currently active save slot. Clock::Source selected_source = Clock::SOURCE_INTERNAL; Clock::Pulse selected_pulse = Clock::PULSE_PPQN_24; - Channel channel[Gravity::OUTPUT_COUNT]; + bool editing_param = false; + bool encoder_reversed = false; + bool refresh_screen = true; }; extern AppState app; diff --git a/firmware/Gravity/display.h b/firmware/Gravity/display.h index dc67447..37b900b 100644 --- a/firmware/Gravity/display.h +++ b/firmware/Gravity/display.h @@ -214,10 +214,10 @@ void swingDivisionMark() { // Human friendly display value for save slot. String displaySaveSlot(int slot) { - if (slot >= 0 && slot < MAX_SAVE_SLOTS / 2) { + if (slot >= 0 && slot < StateManager::MAX_SAVE_SLOTS / 2) { return String("A") + String(slot + 1); - } else if (slot >= MAX_SAVE_SLOTS / 2 && slot <= MAX_SAVE_SLOTS) { - return String("B") + String(slot - (MAX_SAVE_SLOTS / 2) + 1); + } else if (slot >= StateManager::MAX_SAVE_SLOTS / 2 && slot <= StateManager::MAX_SAVE_SLOTS) { + return String("B") + String(slot - (StateManager::MAX_SAVE_SLOTS / 2) + 1); } } @@ -283,7 +283,7 @@ void DisplayMainPage() { break; case PARAM_MAIN_SAVE_DATA: case PARAM_MAIN_LOAD_DATA: - if (app.selected_sub_param == MAX_SAVE_SLOTS) { + if (app.selected_sub_param == StateManager::MAX_SAVE_SLOTS) { mainText = F("x"); subText = F("BACK TO MAIN"); } else { @@ -465,7 +465,7 @@ void UpdateDisplay() { DisplayChannelPage(); } // Global channel select UI. - DisplaySelectedChannel(); + DisplaySelectedChannel(); } while (gravity.display.nextPage()); } @@ -473,16 +473,17 @@ void Bootsplash() { gravity.display.firstPage(); do { int textWidth; + String loadingText = F("LOADING...."); gravity.display.setFont(TEXT_FONT); - textWidth = gravity.display.getStrWidth(SKETCH_NAME); - gravity.display.drawStr(16 + (textWidth / 2), 20, SKETCH_NAME); + textWidth = gravity.display.getStrWidth(StateManager::SKETCH_NAME); + gravity.display.drawStr(16 + (textWidth / 2), 20, StateManager::SKETCH_NAME); - textWidth = gravity.display.getStrWidth(SEMANTIC_VERSION); - gravity.display.drawStr(16 + (textWidth / 2), 32, SEMANTIC_VERSION); + textWidth = gravity.display.getStrWidth(StateManager::SEMANTIC_VERSION); + gravity.display.drawStr(16 + (textWidth / 2), 32, StateManager::SEMANTIC_VERSION); - textWidth = gravity.display.getStrWidth("LOADING...."); - gravity.display.drawStr(26 + (textWidth / 2), 44, "LOADING...."); + textWidth = gravity.display.getStrWidth(loadingText.c_str()); + gravity.display.drawStr(26 + (textWidth / 2), 44, loadingText.c_str()); } while (gravity.display.nextPage()); } diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index 674115f..be4db95 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -15,9 +15,20 @@ #include "app_state.h" +// Define the constants for the current firmware. +const char StateManager::SKETCH_NAME[] = "ALT GRAVITY"; +const char StateManager::SEMANTIC_VERSION[] = "V2.0.0BETA2"; + +// Number of available save slots. +const byte StateManager::MAX_SAVE_SLOTS = 10; +const byte StateManager::TRANSIENT_SLOT = 10; + +// Define the minimum amount of time between EEPROM writes. +const unsigned long StateManager::SAVE_DELAY_MS = 2000; + // Calculate the starting address for EepromData, leaving space for metadata. -static const int METADATA_START_ADDR = 0; -static const int EEPROM_DATA_START_ADDR = sizeof(StateManager::Metadata); +const int StateManager::METADATA_START_ADDR = 0; +const int StateManager::EEPROM_DATA_START_ADDR = sizeof(StateManager::Metadata); StateManager::StateManager() : _isDirty(false), _lastChangeTime(0) {} diff --git a/firmware/Gravity/save_state.h b/firmware/Gravity/save_state.h index cd7fcba..b1a94d6 100644 --- a/firmware/Gravity/save_state.h +++ b/firmware/Gravity/save_state.h @@ -18,17 +18,6 @@ // Forward-declare AppState to avoid circular dependencies. struct AppState; -// Define the constants for the current firmware. -const char SKETCH_NAME[] = "ALT GRAVITY"; -const char SEMANTIC_VERSION[] = "V2.0.0BETA2"; - -// Number of available save slots. -const byte MAX_SAVE_SLOTS = 10; // Count of save slots 0 - 9 to save/load presets. -const byte TRANSIENT_SLOT = 10; // Transient slot index to persist state when powered off. - -// Define the minimum amount of time between EEPROM writes. -static const unsigned long SAVE_DELAY_MS = 2000; - /** * @brief Manages saving and loading of the application state to and from EEPROM. * The number of user slots is defined by MAX_SAVE_SLOTS, and one additional slot @@ -39,6 +28,11 @@ static const unsigned long SAVE_DELAY_MS = 2000; */ class StateManager { public: + static const char SKETCH_NAME[]; + static const char SEMANTIC_VERSION[]; + static const byte MAX_SAVE_SLOTS; + static const byte TRANSIENT_SLOT; + StateManager(); // Populate the AppState instance with values from EEPROM if they exist. @@ -92,6 +86,10 @@ class StateManager { 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; }; -- 2.39.5 From 65dde4d62e8ff3ddc063d2181d03e013daf0b917 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Thu, 24 Jul 2025 15:07:15 +0000 Subject: [PATCH 56/69] Reorganization of library structure to better match Arduino spec (#20) Note, this will also require to you "uninstall and reinstall" the Arduino library due to the library file location changes. Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/20 --- examples/clock_mod/clock_mod.ino | 2 +- firmware/Gravity/Gravity.ino | 4 +- firmware/Gravity/app_state.h | 2 +- firmware/Gravity/channel.h | 2 +- firmware/Gravity/save_state.cpp | 2 +- firmware/Gravity/save_state.h | 2 +- library.properties | 10 ++ analog_input.h => src/analog_input.h | 0 button.h => src/button.h | 0 clock.h => src/clock.h | 2 +- digital_output.h => src/digital_output.h | 0 encoder.h => src/encoder.h | 0 gravity.cpp => src/libGravity.cpp | 4 +- gravity.h => src/libGravity.h | 2 +- peripherials.h => src/peripherials.h | 0 {uClock => src/uClock}/platforms/avr.h | 0 uClock.cpp => src/uClock/uClock.cpp | 2 +- uClock.h => src/uClock/uClock.h | 0 uClock/uClock.h | 180 ----------------------- 19 files changed, 22 insertions(+), 192 deletions(-) create mode 100644 library.properties rename analog_input.h => src/analog_input.h (100%) rename button.h => src/button.h (100%) rename clock.h => src/clock.h (99%) rename digital_output.h => src/digital_output.h (100%) rename encoder.h => src/encoder.h (100%) rename gravity.cpp => src/libGravity.cpp (97%) rename gravity.h => src/libGravity.h (98%) rename peripherials.h => src/peripherials.h (100%) rename {uClock => src/uClock}/platforms/avr.h (100%) rename uClock.cpp => src/uClock/uClock.cpp (99%) rename uClock.h => src/uClock/uClock.h (100%) delete mode 100755 uClock/uClock.h diff --git a/examples/clock_mod/clock_mod.ino b/examples/clock_mod/clock_mod.ino index 8033c0d..cba7091 100644 --- a/examples/clock_mod/clock_mod.ino +++ b/examples/clock_mod/clock_mod.ino @@ -17,7 +17,7 @@ * */ -#include "gravity.h" +#include // Firmware state variables. struct Channel { diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index 18c784e..0a033aa 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -2,7 +2,7 @@ * @file Gravity.ino * @author Adam Wonak (https://github.com/awonak/) * @brief Alt firmware version of Gravity by Sitka Instruments. - * @version v2.0.1 - June 2025 awonak - Full rewrite + * @version v2.0.0 - June 2025 awonak - Full rewrite * @version v1.0 - August 2023 Oleksiy H - Initial release * @date 2025-07-04 * @@ -46,7 +46,7 @@ * */ -#include +#include #include "app_state.h" #include "channel.h" diff --git a/firmware/Gravity/app_state.h b/firmware/Gravity/app_state.h index b53d314..90712df 100644 --- a/firmware/Gravity/app_state.h +++ b/firmware/Gravity/app_state.h @@ -12,7 +12,7 @@ #ifndef APP_STATE_H #define APP_STATE_H -#include +#include #include "channel.h" diff --git a/firmware/Gravity/channel.h b/firmware/Gravity/channel.h index f30b4c8..b668f2f 100644 --- a/firmware/Gravity/channel.h +++ b/firmware/Gravity/channel.h @@ -13,7 +13,7 @@ #define CHANNEL_H #include -#include +#include #include "euclidean.h" diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index be4db95..96a1f48 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -17,7 +17,7 @@ // Define the constants for the current firmware. const char StateManager::SKETCH_NAME[] = "ALT GRAVITY"; -const char StateManager::SEMANTIC_VERSION[] = "V2.0.0BETA2"; +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; diff --git a/firmware/Gravity/save_state.h b/firmware/Gravity/save_state.h index b1a94d6..8f25dd1 100644 --- a/firmware/Gravity/save_state.h +++ b/firmware/Gravity/save_state.h @@ -13,7 +13,7 @@ #define SAVE_STATE_H #include -#include +#include // Forward-declare AppState to avoid circular dependencies. struct AppState; diff --git a/library.properties b/library.properties new file mode 100644 index 0000000..77fe46f --- /dev/null +++ b/library.properties @@ -0,0 +1,10 @@ +name=libGravity +version=2.0.0beta2 +author=Adam Wonak +maintainer=awonak +sentence=Hardware abstraction library for Sitka Instruments Gravity eurorack module +category=Other +license=MIT +url=https://github.com/awonak/libGravity +architectures=avr +depends=uClock,RotaryEncoder,U8g2 \ No newline at end of file diff --git a/analog_input.h b/src/analog_input.h similarity index 100% rename from analog_input.h rename to src/analog_input.h diff --git a/button.h b/src/button.h similarity index 100% rename from button.h rename to src/button.h diff --git a/clock.h b/src/clock.h similarity index 99% rename from clock.h rename to src/clock.h index 4008e0d..613667a 100644 --- a/clock.h +++ b/src/clock.h @@ -15,7 +15,7 @@ #include #include "peripherials.h" -#include "uClock.h" +#include "uClock/uClock.h" // MIDI clock, start, stop, and continue byte definitions - based on MIDI 1.0 Standards. #define MIDI_CLOCK 0xF8 diff --git a/digital_output.h b/src/digital_output.h similarity index 100% rename from digital_output.h rename to src/digital_output.h diff --git a/encoder.h b/src/encoder.h similarity index 100% rename from encoder.h rename to src/encoder.h diff --git a/gravity.cpp b/src/libGravity.cpp similarity index 97% rename from gravity.cpp rename to src/libGravity.cpp index 23d79f8..c4ae4bc 100644 --- a/gravity.cpp +++ b/src/libGravity.cpp @@ -1,5 +1,5 @@ /** - * @file gravity.cpp + * @file libGravity.cpp * @author Adam Wonak (https://github.com/awonak) * @brief Library for building custom scripts for the Sitka Instruments Gravity module. * @version 0.1 @@ -9,7 +9,7 @@ * */ -#include "gravity.h" +#include "libGravity.h" // Initialize the static pointer for the EncoderDir class to null. We want to // have a static pointer to decouple the ISR from the global gravity object. diff --git a/gravity.h b/src/libGravity.h similarity index 98% rename from gravity.h rename to src/libGravity.h index 00539fb..daf5192 100644 --- a/gravity.h +++ b/src/libGravity.h @@ -1,5 +1,5 @@ /** - * @file gravity.h + * @file libGravity.h * @author Adam Wonak (https://github.com/awonak) * @brief Library for building custom scripts for the Sitka Instruments Gravity module. * @version 0.1 diff --git a/peripherials.h b/src/peripherials.h similarity index 100% rename from peripherials.h rename to src/peripherials.h diff --git a/uClock/platforms/avr.h b/src/uClock/platforms/avr.h similarity index 100% rename from uClock/platforms/avr.h rename to src/uClock/platforms/avr.h diff --git a/uClock.cpp b/src/uClock/uClock.cpp similarity index 99% rename from uClock.cpp rename to src/uClock/uClock.cpp index 1003930..94c432e 100755 --- a/uClock.cpp +++ b/src/uClock/uClock.cpp @@ -32,7 +32,7 @@ * DEALINGS IN THE SOFTWARE. */ #include "uClock.h" -#include "uClock/platforms/avr.h" +#include "platforms/avr.h" // // Platform specific timer setup/control diff --git a/uClock.h b/src/uClock/uClock.h similarity index 100% rename from uClock.h rename to src/uClock/uClock.h diff --git a/uClock/uClock.h b/uClock/uClock.h deleted file mode 100755 index d8670b0..0000000 --- a/uClock/uClock.h +++ /dev/null @@ -1,180 +0,0 @@ -/*! - * @file uClock.h - * Project BPM clock generator for Arduino - * @brief A Library to implement BPM clock tick calls using hardware interruption. Supported and tested on AVR boards(ATmega168/328, ATmega16u4/32u4 and ATmega2560) and ARM boards(RPI2040, Teensy, Seedstudio XIAO M0 and ESP32) - * @version 2.2.1 - * @author Romulo Silva - * @date 10/06/2017 - * @license MIT - (c) 2024 - Romulo Silva - contact@midilab.co - * - * Permission is hereby granted, free of charge, to any person obtaining a - * copy of this software and associated documentation files (the "Software"), - * to deal in the Software without restriction, including without limitation - * the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included - * in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS - * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER - * DEALINGS IN THE SOFTWARE. - */ - -#ifndef __U_CLOCK_H__ -#define __U_CLOCK_H__ - -#include -#include - -namespace umodular { namespace clock { - -#define MIN_BPM 1 -#define MAX_BPM 400 - -#define PHASE_FACTOR 16 -#define PLL_X 220 - -#define SECS_PER_MIN (60UL) -#define SECS_PER_HOUR (3600UL) -#define SECS_PER_DAY (SECS_PER_HOUR * 24L) - -class uClockClass { - - public: - enum ClockMode { - INTERNAL_CLOCK = 0, - EXTERNAL_CLOCK - }; - - enum ClockState { - PAUSED = 0, - STARTING, - STARTED - }; - - enum PPQNResolution { - PPQN_1 = 1, - PPQN_2 = 2, - PPQN_4 = 4, - PPQN_8 = 8, - PPQN_12 = 12, - PPQN_24 = 24, - PPQN_48 = 48, - PPQN_96 = 96, - PPQN_384 = 384, - PPQN_480 = 480, - PPQN_960 = 960 - }; - - ClockState clock_state; - - uClockClass(); - - void setOnOutputPPQN(void (*callback)(uint32_t tick)) { - onOutputPPQNCallback = callback; - } - - void setOnSync24(void (*callback)(uint32_t tick)) { - onSync24Callback = callback; - } - - void setOnClockStart(void (*callback)()) { - onClockStartCallback = callback; - } - - void setOnClockStop(void (*callback)()) { - onClockStopCallback = callback; - } - - void init(); - void setOutputPPQN(PPQNResolution resolution); - void setInputPPQN(PPQNResolution resolution); - - void handleTimerInt(); - void handleExternalClock(); - void resetCounters(); - - // external class control - void start(); - void stop(); - void pause(); - void setTempo(float bpm); - float getTempo(); - - // for software timer implementation(fallback for no board support) - void run(); - - // external timming control - void setClockMode(ClockMode tempo_mode); - ClockMode getClockMode(); - void clockMe(); - // for smooth slave tempo calculate display you should raise the - // buffer_size of ext_interval_buffer in between 64 to 128. 254 max size. - // note: this doesn't impact on sync time, only display time getTempo() - // if you dont want to use it, it is default set it to 1 for memory save - void setExtIntervalBuffer(uint8_t buffer_size); - - // elapsed time support - uint8_t getNumberOfSeconds(uint32_t time); - uint8_t getNumberOfMinutes(uint32_t time); - uint8_t getNumberOfHours(uint32_t time); - uint8_t getNumberOfDays(uint32_t time); - uint32_t getNowTimer(); - uint32_t getPlayTime(); - - uint32_t bpmToMicroSeconds(float bpm); - - private: - float inline freqToBpm(uint32_t freq); - float inline constrainBpm(float bpm); - void calculateReferencedata(); - - void (*onOutputPPQNCallback)(uint32_t tick); - void (*onSync24Callback)(uint32_t tick); - void (*onClockStartCallback)(); - void (*onClockStopCallback)(); - - // clock input/output control - PPQNResolution output_ppqn = PPQN_96; - PPQNResolution input_ppqn = PPQN_24; - // output and internal counters, ticks and references - uint32_t tick; - uint32_t int_clock_tick; - uint8_t mod_clock_counter; - uint16_t mod_clock_ref; - - uint8_t mod_sync24_counter; - uint16_t mod_sync24_ref; - uint32_t sync24_tick; - - // external clock control - volatile uint32_t ext_clock_us; - volatile uint32_t ext_clock_tick; - volatile uint32_t ext_interval; - uint32_t last_interval; - uint32_t sync_interval; - - float tempo; - uint32_t start_timer; - ClockMode clock_mode; - - volatile uint32_t * ext_interval_buffer = nullptr; - uint8_t ext_interval_buffer_size; - uint16_t ext_interval_idx; -}; - -} } // end namespace umodular::clock - -extern umodular::clock::uClockClass uClock; - -extern "C" { - extern volatile uint32_t _millis; -} - -#endif /* __U_CLOCK_H__ */ -- 2.39.5 From d1c8ee16a497566824d579606097e3f2c68c10eb Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Thu, 24 Jul 2025 08:35:05 -0700 Subject: [PATCH 57/69] EXT will reset clocks in MIDI clock mode. Add reset behavior for EXT clock input when MIDI clock source is selected. Fixes: https://git.pinkduck.xyz/awonak/libGravity/issues/22 --- firmware/Gravity/Gravity.ino | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index 0a033aa..ebc0fe3 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -37,10 +37,12 @@ * Shift - hold and rotate encoder to change current selected output channel. * * EXT: - * External clock input. When Gravity is set to INTERNAL clock mode, this - * input is used to reset clocks. + * External clock input. When Gravity is set to INTERNAL or MIDI clock + * source, this input is used to reset clocks. * * CV1: + * External analog input used to provide modulation to any channel parameter. + * * CV2: * External analog input used to provide modulation to any channel parameter. * @@ -155,13 +157,16 @@ void HandleIntClockTick(uint32_t tick) { } void HandleExtClockTick() { - if (gravity.clock.InternalSource()) { - // Use EXT as Reset when internally clocked. - ResetOutputs(); - gravity.clock.Reset(); - } else { - // Register clock tick. - gravity.clock.Tick(); + switch (app.selected_source) { + case Clock::SOURCE_INTERNAL: + case Clock::SOURCE_EXTERNAL_MIDI: + // Use EXT as Reset when not used for clock source. + ResetOutputs(); + gravity.clock.Reset(); + break; + default: + // Register EXT cv clock tick. + gravity.clock.Tick(); } app.refresh_screen = true; } -- 2.39.5 From dd7217d04ef1a95a0bec565fcded10bd0667b76f Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Thu, 24 Jul 2025 18:27:24 -0700 Subject: [PATCH 58/69] Fix euclidean hit mod --- firmware/Gravity/channel.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firmware/Gravity/channel.h b/firmware/Gravity/channel.h index b668f2f..df88785 100644 --- a/firmware/Gravity/channel.h +++ b/firmware/Gravity/channel.h @@ -255,7 +255,7 @@ class Channel { int step_mod = _calculateMod(CV_DEST_EUC_STEPS, cv1_val, cv2_val, 0, MAX_PATTERN_LEN); pattern.SetSteps(base_euc_steps + step_mod); - int hit_mod = _calculateMod(CV_DEST_EUC_HITS, cv1_val, cv2_val, 0, MAX_PATTERN_LEN); + int hit_mod = _calculateMod(CV_DEST_EUC_HITS, cv1_val, cv2_val, 0, pattern.GetSteps()); pattern.SetHits(base_euc_hits + hit_mod); // After all cvmod values are updated, recalculate clock pulse modifiers. -- 2.39.5 From 19473db67e761c7f96a3ff0d4e33e87dfb568ca1 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Thu, 24 Jul 2025 18:38:34 -0700 Subject: [PATCH 59/69] bump version in code --- firmware/Gravity/save_state.cpp | 2 +- library.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index 96a1f48..e701c4f 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -17,7 +17,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 library.properties file. +const char StateManager::SEMANTIC_VERSION[] = "V2.0.0BETA3"; // NOTE: This should match the version in the library.properties file. // Number of available save slots. const byte StateManager::MAX_SAVE_SLOTS = 10; diff --git a/library.properties b/library.properties index 77fe46f..1390c78 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=libGravity -version=2.0.0beta2 +version=2.0.0beta3 author=Adam Wonak maintainer=awonak sentence=Hardware abstraction library for Sitka Instruments Gravity eurorack module -- 2.39.5 From b6402380c0866051623be5c790e2fb74b2a5ffac Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 26 Jul 2025 18:51:18 -0700 Subject: [PATCH 60/69] fixed bug in cv mod of clock multiplication upper range. --- firmware/Gravity/channel.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firmware/Gravity/channel.h b/firmware/Gravity/channel.h index df88785..114e7fd 100644 --- a/firmware/Gravity/channel.h +++ b/firmware/Gravity/channel.h @@ -238,7 +238,7 @@ class Channel { } 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); + cvmod_clock_mod_index = constrain(base_clock_mod_index + dest_mod, 0, MOD_CHOICE_SIZE - 1); int prob_mod = _calculateMod(CV_DEST_PROB, cv1_val, cv2_val, -50, 50); cvmod_probability = constrain(base_probability + prob_mod, 0, 100); -- 2.39.5 From fc17afc9a1dbe5427e587a271716d3e14c56aee6 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 9 Aug 2025 23:57:10 +0000 Subject: [PATCH 61/69] Remove Reset State (#26) This feature is essentially overlapping with loading default save slots. I need the few bytes it affords me. Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/26 --- firmware/Gravity/Gravity.ino | 9 --------- firmware/Gravity/display.h | 14 ++------------ 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index ebc0fe3..1944947 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -220,12 +220,6 @@ void HandleEncoderPressed() { 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. @@ -305,9 +299,6 @@ void editMainParameter(int val) { 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; diff --git a/firmware/Gravity/display.h b/firmware/Gravity/display.h index 37b900b..d71c801 100644 --- a/firmware/Gravity/display.h +++ b/firmware/Gravity/display.h @@ -47,7 +47,7 @@ const uint8_t TEXT_FONT[437] U8G2_FONT_SECTION("velvetscreen") PROGMEM = * https://stncrn.github.io/u8g2-unifont-helper/ * "%/0123456789ABCDEFILNORSTUVXx" */ -const uint8_t LARGE_FONT[766] U8G2_FONT_SECTION("stk-l") = +const uint8_t LARGE_FONT[766] U8G2_FONT_SECTION("stk-l") PROGMEM = "\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" @@ -104,7 +104,6 @@ enum ParamsMainPage : uint8_t { PARAM_MAIN_ENCODER_DIR, PARAM_MAIN_SAVE_DATA, PARAM_MAIN_LOAD_DATA, - PARAM_MAIN_RESET_STATE, PARAM_MAIN_FACTORY_RESET, PARAM_MAIN_LAST, }; @@ -297,15 +296,6 @@ void DisplayMainPage() { : 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"); @@ -321,7 +311,7 @@ void DisplayMainPage() { 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")}; + String menu_items[PARAM_MAIN_LAST] = {F("TEMPO"), F("SOURCE"), F("PULSE OUT"), F("ENCODER DIR"), F("SAVE"), F("LOAD"), F("ERASE")}; drawMenuItems(menu_items, PARAM_MAIN_LAST); } -- 2.39.5 From 872af30fbcbe8c89d9872b5f6efd254ca963ef5c Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 9 Aug 2025 23:59:24 +0000 Subject: [PATCH 62/69] Refactor CV Mod (#24) Move cv mod calculation to processClockTick. This is less ideas because it is an ISR, but it saves a significant amount of memory. Performance doesn't seem to take much of a hit. Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/24 --- firmware/Gravity/Gravity.ino | 13 --- firmware/Gravity/channel.h | 190 +++++++++++++------------------- firmware/Gravity/display.h | 16 +-- firmware/Gravity/save_state.cpp | 14 +-- 4 files changed, 95 insertions(+), 138 deletions(-) diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index 1944947..4b19f2c 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -91,19 +91,6 @@ 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); diff --git a/firmware/Gravity/channel.h b/firmware/Gravity/channel.h index 114e7fd..29a9ff3 100644 --- a/firmware/Gravity/channel.h +++ b/firmware/Gravity/channel.h @@ -70,14 +70,6 @@ class Channel { base_duty_cycle = 50; base_offset = 0; base_swing = 50; - base_euc_steps = 1; - base_euc_hits = 1; - - 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; @@ -88,78 +80,100 @@ class Channel { _recalculatePulses(); } + bool isCvModActive() const { return cv1_dest != CV_DEST_NONE || cv2_dest != CV_DEST_NONE; } + // 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(); - } } // 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); - } + 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); - } + pattern.SetHits(val); } - void setCv1Dest(CvDestination dest) { cv1_dest = dest; } - void setCv2Dest(CvDestination dest) { cv2_dest = dest; } + void setCv1Dest(CvDestination dest) { + cv1_dest = dest; + _recalculatePulses(); + } + void setCv2Dest(CvDestination dest) { + cv2_dest = dest; + _recalculatePulses(); + } 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() const { return base_probability; } + int getDutyCycle() const { return base_duty_cycle; } + int getOffset() const { return base_offset; } + int getSwing() const { return base_swing; } + int getClockMod() const { return pgm_read_word_near(&CLOCK_MOD[getClockModIndex()]); } + int getClockModIndex() const { return base_clock_mod_index; } + byte getSteps() const { return pattern.GetSteps(); } + byte getHits() const { return pattern.GetHits(); } - 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; } + // Getters that calculate the value with CV modulation applied. + int getClockModIndexWithMod(int cv1_val, int cv2_val) { + int clock_mod_index = _calculateMod(CV_DEST_MOD, cv1_val, cv2_val, -(MOD_CHOICE_SIZE / 2), MOD_CHOICE_SIZE / 2); + return constrain(base_clock_mod_index + clock_mod_index, 0, MOD_CHOICE_SIZE - 1); + } - 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; } + int getClockModWithMod(int cv1_val, int cv2_val) { + int clock_mod = _calculateMod(CV_DEST_MOD, cv1_val, cv2_val, -(MOD_CHOICE_SIZE / 2), MOD_CHOICE_SIZE / 2); + return pgm_read_word_near(&CLOCK_MOD[getClockModIndexWithMod(cv1_val, cv2_val)]); + } + + int getProbabilityWithMod(int cv1_val, int cv2_val) { + int prob_mod = _calculateMod(CV_DEST_PROB, cv1_val, cv2_val, -50, 50); + return constrain(base_probability + prob_mod, 0, 100); + } + + int getDutyCycleWithMod(int cv1_val, int cv2_val) { + int duty_mod = _calculateMod(CV_DEST_DUTY, cv1_val, cv2_val, -50, 50); + return constrain(base_duty_cycle + duty_mod, 1, 99); + } + + int getOffsetWithMod(int cv1_val, int cv2_val) { + int offset_mod = _calculateMod(CV_DEST_OFFSET, cv1_val, cv2_val, -50, 50); + return constrain(base_offset + offset_mod, 0, 99); + } + + int getSwingWithMod(int cv1_val, int cv2_val) { + int swing_mod = _calculateMod(CV_DEST_SWING, cv1_val, cv2_val, -25, 25); + return constrain(base_swing + swing_mod, 50, 95); + } + + byte getStepsWithMod(int cv1_val, int cv2_val) { + int step_mod = _calculateMod(CV_DEST_EUC_STEPS, cv1_val, cv2_val, 0, MAX_PATTERN_LEN); + return constrain(pattern.GetSteps() + step_mod, 1, MAX_PATTERN_LEN); + } + + byte getHitsWithMod(int cv1_val, int cv2_val) { + // The number of hits is dependent on the modulated number of steps. + byte modulated_steps = getStepsWithMod(cv1_val, cv2_val); + int hit_mod = _calculateMod(CV_DEST_EUC_HITS, cv1_val, cv2_val, 0, modulated_steps); + return constrain(pattern.GetHits() + hit_mod, 1, modulated_steps); + } void toggleMute() { mute = !mute; } @@ -176,6 +190,13 @@ class Channel { return; } + if (isCvModActive()) _recalculatePulses(); + + int cv1 = gravity.cv1.Read(); + int cv2 = gravity.cv2.Read(); + int cvmod_clock_mod_index = getClockModIndexWithMod(cv1, cv2); + int cvmod_probability = getProbabilityWithMod(cv1, cv2); + const uint16_t mod_pulses = pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]); // Conditionally apply swing on down beats. @@ -211,56 +232,6 @@ class Channel { 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, MOD_CHOICE_SIZE - 1); - - 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) { @@ -270,13 +241,19 @@ class Channel { } 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); + int cv1 = gravity.cv1.Read(); + int cv2 = gravity.cv2.Read(); + int clock_mod_index = getClockModIndexWithMod(cv1, cv2); + int duty_cycle = getDutyCycleWithMod(cv1, cv2); + int offset = getOffsetWithMod(cv1, cv2); + int swing = getSwingWithMod(cv1, cv2); + const uint16_t mod_pulses = pgm_read_word_near(&CLOCK_MOD_PULSES[clock_mod_index]); + _duty_pulses = max((long)((mod_pulses * (100L - duty_cycle)) / 100L), 1L); + _offset_pulses = (long)((mod_pulses * (100L - offset)) / 100L); // Calculate the down beat swing amount. - if (cvmod_swing > 50) { - int shifted_swing = cvmod_swing - 50; + if (swing > 50) { + int shifted_swing = swing - 50; _swing_pulse_amount = (long)((mod_pulses * (100L - shifted_swing)) / 100L); } else { _swing_pulse_amount = 0; @@ -289,15 +266,6 @@ class Channel { 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; diff --git a/firmware/Gravity/display.h b/firmware/Gravity/display.h index d71c801..d83de64 100644 --- a/firmware/Gravity/display.h +++ b/firmware/Gravity/display.h @@ -328,10 +328,12 @@ void DisplayChannelPage() { // When editing a param, just show the base value. When not editing show // the value with cv mod. bool withCvMod = !app.editing_param; + int cv1 = gravity.cv1.Read(); + int cv2 = gravity.cv2.Read(); switch (app.selected_param) { case PARAM_CH_MOD: { - int mod_value = ch.getClockMod(withCvMod); + int mod_value = withCvMod ? ch.getClockModWithMod(cv1, cv2): ch.getClockMod(); if (mod_value > 1) { mainText = F("/"); mainText += String(mod_value); @@ -344,30 +346,30 @@ void DisplayChannelPage() { break; } case PARAM_CH_PROB: - mainText = String(ch.getProbability(withCvMod)) + F("%"); + mainText = String(withCvMod ? ch.getProbabilityWithMod(cv1, cv2) : ch.getProbability()) + F("%"); subText = F("HIT CHANCE"); break; case PARAM_CH_DUTY: - mainText = String(ch.getDutyCycle(withCvMod)) + F("%"); + mainText = String(withCvMod ? ch.getDutyCycleWithMod(cv1, cv2) : ch.getDutyCycle()) + F("%"); subText = F("PULSE WIDTH"); break; case PARAM_CH_OFFSET: - mainText = String(ch.getOffset(withCvMod)) + F("%"); + mainText = String(withCvMod ? ch.getOffsetWithMod(cv1, cv2) : ch.getOffset()) + F("%"); subText = F("SHIFT HIT"); break; case PARAM_CH_SWING: ch.getSwing() == 50 ? mainText = F("OFF") - : mainText = String(ch.getSwing(withCvMod)) + F("%"); + : mainText = String(withCvMod ? ch.getSwingWithMod(cv1, cv2) : ch.getSwing()) + F("%"); subText = "DOWN BEAT"; swingDivisionMark(); break; case PARAM_CH_EUC_STEPS: - mainText = String(ch.getSteps(withCvMod)); + mainText = String(withCvMod ? ch.getStepsWithMod(cv1, cv2) : ch.getSteps()); subText = "EUCLID STEPS"; break; case PARAM_CH_EUC_HITS: - mainText = String(ch.getHits(withCvMod)); + mainText = String(withCvMod ? ch.getHitsWithMod(cv1, cv2) : ch.getHits()); subText = "EUCLID HITS"; break; case PARAM_CH_CV1_DEST: diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index e701c4f..f3f77c1 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -148,13 +148,13 @@ void StateManager::_saveState(const AppState& app, byte slot_index) { 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.base_clock_mod_index = ch.getClockModIndex(); + save_ch.base_probability = ch.getProbability(); + save_ch.base_duty_cycle = ch.getDutyCycle(); + save_ch.base_offset = ch.getOffset(); + save_ch.base_swing = ch.getSwing(); + save_ch.base_euc_steps = ch.getSteps(); + save_ch.base_euc_hits = ch.getHits(); save_ch.cv1_dest = static_cast(ch.getCv1Dest()); save_ch.cv2_dest = static_cast(ch.getCv2Dest()); } -- 2.39.5 From 1161da38c1e5e9d09352b52e137f04ecfa4b6453 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sun, 10 Aug 2025 00:25:06 +0000 Subject: [PATCH 63/69] Add menu options for using cv input as Clock Run/Reset (#25) Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/25 --- firmware/Gravity/Gravity.ino | 35 ++++++++++++++++++++++++++++++++- firmware/Gravity/app_state.h | 2 ++ firmware/Gravity/display.h | 34 ++++++++++++++++++++++++++++++-- firmware/Gravity/save_state.cpp | 8 +++++++- firmware/Gravity/save_state.h | 2 ++ src/analog_input.h | 14 +++++++++++++ src/digital_output.h | 1 - 7 files changed, 91 insertions(+), 5 deletions(-) diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index 4b19f2c..8618f46 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -42,7 +42,7 @@ * * CV1: * External analog input used to provide modulation to any channel parameter. - * + * * CV2: * External analog input used to provide modulation to any channel parameter. * @@ -91,6 +91,9 @@ 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); @@ -158,6 +161,27 @@ void HandleExtClockTick() { 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. // @@ -263,6 +287,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); @@ -279,6 +311,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..0f06f02 100644 --- a/firmware/Gravity/app_state.h +++ b/firmware/Gravity/app_state.h @@ -25,6 +25,8 @@ struct AppState { 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; diff --git a/firmware/Gravity/display.h b/firmware/Gravity/display.h index d83de64..d5a5f6e 100644 --- a/firmware/Gravity/display.h +++ b/firmware/Gravity/display.h @@ -100,6 +100,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, @@ -259,6 +261,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) { @@ -311,7 +341,7 @@ void DisplayMainPage() { 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("ERASE")}; + 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")}; drawMenuItems(menu_items, PARAM_MAIN_LAST); } @@ -457,7 +487,7 @@ void UpdateDisplay() { DisplayChannelPage(); } // Global channel select UI. - DisplaySelectedChannel(); + DisplaySelectedChannel(); } while (gravity.display.nextPage()); } diff --git a/firmware/Gravity/save_state.cpp b/firmware/Gravity/save_state.cpp index f3f77c1..4950d7e 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -17,7 +17,7 @@ // Define the constants for the current firmware. const char StateManager::SKETCH_NAME[] = "ALT GRAVITY"; -const char StateManager::SEMANTIC_VERSION[] = "V2.0.0BETA3"; // NOTE: This should match the version in the library.properties file. +const char StateManager::SEMANTIC_VERSION[] = "V2.0.0BETA4"; // NOTE: This should match the version in the library.properties file. // Number of available save slots. const byte StateManager::MAX_SAVE_SLOTS = 10; @@ -87,6 +87,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(); @@ -140,6 +142,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 @@ -179,6 +183,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 8f25dd1..34bfffe 100644 --- a/firmware/Gravity/save_state.h +++ b/firmware/Gravity/save_state.h @@ -76,6 +76,8 @@ class StateManager { 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..2be670a 100644 --- a/src/analog_input.h +++ b/src/analog_input.h @@ -19,6 +19,8 @@ const int CALIBRATED_HIGH = 512; class AnalogInput { public: + static const int GATE_THRESHOLD = 0; + AnalogInput() {} ~AnalogInput() {} @@ -74,6 +76,18 @@ class AnalogInput { */ 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_; diff --git a/src/digital_output.h b/src/digital_output.h index 9c4cfc8..ccb6f04 100644 --- a/src/digital_output.h +++ b/src/digital_output.h @@ -82,7 +82,6 @@ class DigitalOutput { unsigned long last_triggered_; uint8_t trigger_duration_; uint8_t cv_pin_; - uint8_t led_pin_; bool on_; void update(uint8_t state) { -- 2.39.5 From 7c0262840385bb517a37b57fa117245e74b29c00 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sun, 10 Aug 2025 00:26:20 +0000 Subject: [PATCH 64/69] Add more EXT clock source options (#23) Fixes https://github.com/awonak/alt-gravity/issues/12 Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/23 --- firmware/Gravity/display.h | 6 ++++++ src/clock.h | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/firmware/Gravity/display.h b/firmware/Gravity/display.h index d5a5f6e..faeb4d1 100644 --- a/firmware/Gravity/display.h +++ b/firmware/Gravity/display.h @@ -256,6 +256,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..ea39214 100644 --- a/src/clock.h +++ b/src/clock.h @@ -35,6 +35,8 @@ class Clock { SOURCE_INTERNAL, SOURCE_EXTERNAL_PPQN_24, SOURCE_EXTERNAL_PPQN_4, + SOURCE_EXTERNAL_PPQN_2, + SOURCE_EXTERNAL_PPQN_1, SOURCE_EXTERNAL_MIDI, SOURCE_LAST, }; @@ -96,6 +98,14 @@ class Clock { 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); -- 2.39.5 From c5965aa1f7aeaa830f55c3f824f4f43d10b8f36c Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sat, 9 Aug 2025 18:45:21 -0700 Subject: [PATCH 65/69] bug fix - need to recalculate pulses when mod duty and swing are changed. --- firmware/Gravity/channel.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/firmware/Gravity/channel.h b/firmware/Gravity/channel.h index 29a9ff3..fd97537 100644 --- a/firmware/Gravity/channel.h +++ b/firmware/Gravity/channel.h @@ -86,6 +86,7 @@ class Channel { void setClockMod(int index) { base_clock_mod_index = constrain(index, 0, MOD_CHOICE_SIZE - 1); + _recalculatePulses(); } void setProbability(int prob) { @@ -94,13 +95,16 @@ class Channel { void setDutyCycle(int duty) { base_duty_cycle = constrain(duty, 1, 99); + _recalculatePulses(); } void setOffset(int off) { base_offset = constrain(off, 0, 99); + _recalculatePulses(); } void setSwing(int val) { base_swing = constrain(val, 50, 95); + _recalculatePulses(); } // Euclidean -- 2.39.5 From 6ada2aba30fef81a4b182737280208e8be2ad742 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Sun, 10 Aug 2025 02:47:59 +0000 Subject: [PATCH 66/69] Add option to rotate the display (#27) I needed to cut the bootsplash to make room for adding this features. Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/27 --- firmware/Gravity/Gravity.ino | 59 +++++++++++++++++---------------- firmware/Gravity/app_state.h | 1 + firmware/Gravity/display.h | 30 ++++------------- firmware/Gravity/save_state.cpp | 3 +- firmware/Gravity/save_state.h | 5 +-- library.properties | 2 +- src/clock.h | 9 ++--- 7 files changed, 46 insertions(+), 63 deletions(-) diff --git a/firmware/Gravity/Gravity.ino b/firmware/Gravity/Gravity.ino index 8618f46..aef4ab8 100644 --- a/firmware/Gravity/Gravity.ino +++ b/firmware/Gravity/Gravity.ino @@ -66,10 +66,6 @@ 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); @@ -213,31 +209,34 @@ 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_FACTORY_RESET) { - if (app.selected_sub_param == 0) { // Erase - // Show bootsplash during slow erase operation. - Bootsplash(); - stateManager.factoryReset(app); - InitGravity(app); - } + 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; + stateManager.loadData(app, app.selected_save_slot); + 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. @@ -313,6 +312,7 @@ void editMainParameter(int val) { } // 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: @@ -381,6 +381,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 0f06f02..c02f8a3 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 faeb4d1..7c4abfa 100644 --- a/firmware/Gravity/display.h +++ b/firmware/Gravity/display.h @@ -104,6 +104,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_FACTORY_RESET, @@ -256,9 +257,6 @@ 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; @@ -316,6 +314,10 @@ void DisplayMainPage() { mainText = F("DIR"); subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("REVERSED"); break; + case PARAM_MAIN_ROTATE_DISP: + mainText = F("ROT"); + subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("FLIPPED"); + break; case PARAM_MAIN_SAVE_DATA: case PARAM_MAIN_LOAD_DATA: if (app.selected_sub_param == StateManager::MAX_SAVE_SLOTS) { @@ -347,7 +349,7 @@ void DisplayMainPage() { 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("CLK RUN"), F("CLK RESET"), F("PULSE OUT"), F("ENCODER DIR"), F("SAVE"), F("LOAD"), F("ERASE")}; + String menu_items[PARAM_MAIN_LAST] = {F("TEMPO"), F("SOURCE"), F("CLK RUN"), F("CLK RESET"), F("PULSE OUT"), F("ENCODER DIR"), F("ROTATE DISP"), F("SAVE"), F("LOAD"), F("ERASE")}; drawMenuItems(menu_items, PARAM_MAIN_LAST); } @@ -369,7 +371,7 @@ void DisplayChannelPage() { switch (app.selected_param) { case PARAM_CH_MOD: { - int mod_value = withCvMod ? ch.getClockModWithMod(cv1, cv2): ch.getClockMod(); + int mod_value = withCvMod ? ch.getClockModWithMod(cv1, cv2) : ch.getClockMod(); if (mod_value > 1) { mainText = F("/"); mainText += String(mod_value); @@ -497,22 +499,4 @@ void UpdateDisplay() { } 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/save_state.cpp b/firmware/Gravity/save_state.cpp index 4950d7e..ff00005 100644 --- a/firmware/Gravity/save_state.cpp +++ b/firmware/Gravity/save_state.cpp @@ -17,7 +17,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 library.properties file. +const char StateManager::SEMANTIC_VERSION[] = "2.0.0"; // NOTE: This should match the version in the library.properties file. // Number of available save slots. const byte StateManager::MAX_SAVE_SLOTS = 10; @@ -212,6 +212,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(); diff --git a/firmware/Gravity/save_state.h b/firmware/Gravity/save_state.h index 34bfffe..29226e7 100644 --- a/firmware/Gravity/save_state.h +++ b/firmware/Gravity/save_state.h @@ -52,11 +52,12 @@ class StateManager { // This struct holds the data that identifies the firmware version. struct Metadata { - char sketch_name[16]; - char version[16]; + char sketch_name[12]; + char version[5]; // Additional global/hardware settings byte selected_save_slot; bool encoder_reversed; + bool rotate_display; }; struct ChannelState { byte base_clock_mod_index; diff --git a/library.properties b/library.properties index 1390c78..bce41dd 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=libGravity -version=2.0.0beta3 +version=2.0.0 author=Adam Wonak maintainer=awonak sentence=Hardware abstraction library for Sitka Instruments Gravity eurorack module diff --git a/src/clock.h b/src/clock.h index ea39214..9bc695c 100644 --- a/src/clock.h +++ b/src/clock.h @@ -35,7 +35,6 @@ class Clock { SOURCE_INTERNAL, SOURCE_EXTERNAL_PPQN_24, SOURCE_EXTERNAL_PPQN_4, - SOURCE_EXTERNAL_PPQN_2, SOURCE_EXTERNAL_PPQN_1, SOURCE_EXTERNAL_MIDI, SOURCE_LAST, @@ -43,9 +42,9 @@ class Clock { enum Pulse { PULSE_NONE, - PULSE_PPQN_1, - PULSE_PPQN_4, PULSE_PPQN_24, + PULSE_PPQN_4, + PULSE_PPQN_1, PULSE_LAST, }; @@ -98,10 +97,6 @@ class Clock { 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); -- 2.39.5 From 4bcd6180733a84f4d9a108ec6e76af38710e00a7 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Wed, 13 Aug 2025 07:18:45 -0700 Subject: [PATCH 67/69] Add skeleton app to examples --- examples/skeleton/skeleton.ino | 118 +++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 examples/skeleton/skeleton.ino diff --git a/examples/skeleton/skeleton.ino b/examples/skeleton/skeleton.ino new file mode 100644 index 0000000..eb07186 --- /dev/null +++ b/examples/skeleton/skeleton.ino @@ -0,0 +1,118 @@ +/** + * @file skeleton.ino + * @author YOUR_NAME () + * @brief Skeleton app for Sitka Instruments Gravity. + * @version v1.0.0 - August 2025 + * @date 2025-08-12 + * + * @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com + * + * Skeleton app for basic structure of a new firmware for Sitka Instruments + * Gravity using the libGravity library. + * + * 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 + + + +// Global state for settings and app behavior. +struct AppState { + int tempo = Clock::DEFAULT_TEMPO; + Clock::Source selected_source = Clock::SOURCE_INTERNAL; + // Add app specific state variables here. +}; + +AppState app; + +// +// Arduino setup and loop. +// + +void setup() { + // Start Gravity. + gravity.Init(); + + // 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(); + + // Non-ISR loop behavior. +} + +// +// Firmware handlers for clocks. +// + +void HandleIntClockTick(uint32_t tick) { + bool refresh = false; + for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { + // Process each output tick handlers. + } +} + +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. + gravity.clock.Reset(); + break; + default: + // Register EXT cv clock tick. + gravity.clock.Tick(); + } +} + +// +// UI handlers for encoder and buttons. +// + +void HandlePlayPressed() { +} + +void HandleEncoderPressed() { +} + +void HandleRotate(int val) { +} + +void HandlePressedRotate(int val) { +} + +// +// Application logic goes here. +// -- 2.39.5 From b5029bde888ef0b5d31a81bfbe85785b262bdb2f Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Wed, 13 Aug 2025 07:19:06 -0700 Subject: [PATCH 68/69] add skeleton app to examples --- examples/skeleton/skeleton.ino | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/skeleton/skeleton.ino b/examples/skeleton/skeleton.ino index eb07186..a71b878 100644 --- a/examples/skeleton/skeleton.ino +++ b/examples/skeleton/skeleton.ino @@ -2,8 +2,8 @@ * @file skeleton.ino * @author YOUR_NAME () * @brief Skeleton app for Sitka Instruments Gravity. - * @version v1.0.0 - August 2025 - * @date 2025-08-12 + * @version vX.Y.Z - MONTH YEAR YOUR_NAME + * @date YYYY-MM-DD * * @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com * -- 2.39.5 From 3f670fa9f78b69327ce6b37abf4c8d2ae76267d7 Mon Sep 17 00:00:00 2001 From: Adam Wonak Date: Wed, 13 Aug 2025 07:42:02 -0700 Subject: [PATCH 69/69] Update docs and example firmware --- README.md | 19 +++++++++++++++---- .../calibrate_analog/calibrate_analog.ino | 4 ++-- .../calibrate_analog2/calibrate_analog2.ino | 4 ++-- examples/hardware_test/hardware_test.ino | 16 ++++++++-------- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 569aeed..7afaabf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Sitka Instruments Gravity Firmware Abstraction -This library helps make writing firmware easier by abstracting away the initialization and peripheral interactions. Now your firmware code can just focus on the logic and behavior of the app, and keep the low level code neatly tucked away in this library. +This library helps make writing firmware for the [Sitka Instruments Gravity](https://sitkainstruments.com/gravity/) eurorack module easier by abstracting away the initialization and peripheral interactions. Now your firmware code can just focus on the logic and behavior of the app, and keep the low level code neatly tucked away in this library. + +The latest releases of all Sitka Instruments Gravity firmware builds can be found on the [Updater](https://sitkainstruments.com/gravity/updater/) page. You can use this page to flash the latest build directly to the Arduino Nano on the back of your module. ## Installation @@ -17,13 +19,14 @@ Common directory locations: * [uClock](https://github.com/midilab/uClock) [MIT] - (Included with this repo) Handle clock tempo, external clock input, and internal clock timer handler. * [RotateEncoder](https://github.com/mathertel/RotaryEncoder) [BSD] - Library for reading and interpreting encoder rotation. * [U8g2](https://github.com/olikraus/u8g2/) [MIT] - Graphics helper library. +* [NeoHWSerial](https://github.com/SlashDevin/NeoHWSerial) [GPL] - Hardware serial library with attachInterrupt. ## Example Here's a trivial example showing some of the ways to interact with the library. This script rotates the active clock channel according to the set tempo. The encoder can change the temo or rotation direction. The play/pause button will toggle the clock activity on or off. The shift button will freeze the clock from advancing the channel rotation. ```cpp -#include "gravity.h" +#include "libGravity.h" byte idx = 0; bool reversed = false; @@ -75,11 +78,11 @@ void HandlePlayPressed() { } } -void HandleRotate(Direction dir, int val) { +void HandleRotate(int val) { if (selected_param == 0) { gravity.clock.SetTempo(gravity.clock.Tempo() + val); } else if (selected_param == 1) { - reversed = (dir == DIRECTION_DECREMENT); + reversed = (val < 0); } } @@ -111,6 +114,14 @@ void UpdateDisplay() { } ``` +**Builing New Firmware Using libGravity** + +When starting a new firmware sketch you can use the [skeleton](examples/skeleton/skeleton.ino) app as a place to start. + +**Building New Firmware from scratch** + +If you do not want to use the libGravity hardware abstraction library and want to roll your own vanilla firmware, take a look at the [peripherials.h](src/peripherials.h) file for the pinout definitions used by the module. + ### Build for release ``` diff --git a/examples/calibrate_analog/calibrate_analog.ino b/examples/calibrate_analog/calibrate_analog.ino index c3e6a5f..388494f 100644 --- a/examples/calibrate_analog/calibrate_analog.ino +++ b/examples/calibrate_analog/calibrate_analog.ino @@ -17,7 +17,7 @@ * TODO: Store the calibration value in EEPROM. */ -#include "gravity.h" +#include "libGravity.h" #define TEXT_FONT u8g2_font_profont11_tf #define INDICATOR_FONT u8g2_font_open_iconic_arrow_1x_t @@ -43,7 +43,7 @@ void NextCalibrationPoint() { selected_param = (selected_param + 1) % 6; } -void CalibrateCV(Direction dir, int val) { +void CalibrateCV(int val) { AnalogInput* cv = (selected_param > 2) ? &gravity.cv2 : &gravity.cv1; switch (selected_param % 3) { case 0: diff --git a/examples/calibrate_analog2/calibrate_analog2.ino b/examples/calibrate_analog2/calibrate_analog2.ino index ac5d772..48958d2 100644 --- a/examples/calibrate_analog2/calibrate_analog2.ino +++ b/examples/calibrate_analog2/calibrate_analog2.ino @@ -14,7 +14,7 @@ * */ -#include "gravity.h" +#include "libGravity.h" #define TEXT_FONT u8g2_font_profont11_tf @@ -39,7 +39,7 @@ void NextCalibrationPoint() { selected_param = (selected_param + 1) % 2; } -void CalibrateCV(Direction dir, int val) { +void CalibrateCV(int val) { // AnalogInput* cv = (selected_param > 2) ? &gravity.cv2 : &gravity.cv1; AnalogInput* cv = &gravity.cv1; switch (selected_param % 2) { diff --git a/examples/hardware_test/hardware_test.ino b/examples/hardware_test/hardware_test.ino index 5f3d345..9ba6b90 100644 --- a/examples/hardware_test/hardware_test.ino +++ b/examples/hardware_test/hardware_test.ino @@ -1,4 +1,4 @@ -#include "gravity.h" +#include "libGravity.h" byte idx = 0; bool reversed = false; @@ -33,28 +33,28 @@ void IntClock(uint32_t tick) { if (tick % 12 == 0 && ! freeze) { gravity.outputs[idx].Low(); if (reversed) { - idx = (idx == 0) ? OUTPUT_COUNT - 1 : idx - 1; + idx = (idx == 0) ? Gravity::OUTPUT_COUNT - 1 : idx - 1; } else { - idx = (idx + 1) % OUTPUT_COUNT; + idx = (idx + 1) % Gravity::OUTPUT_COUNT; } gravity.outputs[idx].High(); } } void HandlePlayPressed() { - gravity.clock.Pause(); + gravity.clock.Stop(); if (gravity.clock.IsPaused()) { - for (int i = 0; i < OUTPUT_COUNT; i++) { + for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) { gravity.outputs[i].Low(); } } } -void HandleRotate(Direction dir, int val) { +void HandleRotate(int val) { if (selected_param == 0) { gravity.clock.SetTempo(gravity.clock.Tempo() + val); } else if (selected_param == 1) { - reversed = (dir == DIRECTION_DECREMENT); + reversed = (val < 0); } } @@ -80,7 +80,7 @@ void UpdateDisplay() { gravity.display.print("Direction: "); gravity.display.print((reversed) ? "Backward" : "Forward"); - gravity.display.drawChar(0, selected_param * 10, 0x10, 1, 0, 1); + gravity.display.drawStr(0, selected_param * 10, "x"); gravity.display.display(); } \ No newline at end of file -- 2.39.5