4 Commits

22 changed files with 775 additions and 487 deletions

View File

@ -1,18 +1,6 @@
# Sitka Instruments Gravity Firmware Abstraction
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.
## Project Code Layout
* [`src/`](src/) - **libGravity**: This is the hardware abstraction library used to simplify the creation of new Gravity module firmware by providing common reusable wrappers around the module peripherials like [DigitalOutput](src/digital_output.h#L18) providing methods like [`Update(uint8_t state)`](src/digital_output.h#L45) which allow you to set that output channel voltage high or low, and common module behavior like [Clock](src/clock.h#L30) which provides handlers like [`AttachExtHandler(callback)`](src/clock.h#L69) which takes a callback function to handle external clock tick behavior when receiving clock trigger.
* [`firmware/Gravity`](firmware/Gravity/) - **Alt Gravity**: This is the implementation of the default 6-channel trigger/gate clock modulation firmware. This is a full rewrite of the original firmware designed to use `libGravity` with a focus on open source friendlines.
* `firmware/GridSeq` - **GridSeq**: Comming Soon.
* [`examples/skeleton`](examples/skeleton/skeleton.ino) - **Skeleton**: This is the bare bones scaffloding for a `libGravity` firmware app.
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.
## Installation
@ -29,14 +17,13 @@ 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 "libGravity.h"
#include "gravity.h"
byte idx = 0;
bool reversed = false;
@ -88,11 +75,11 @@ void HandlePlayPressed() {
}
}
void HandleRotate(int val) {
void HandleRotate(Direction dir, int val) {
if (selected_param == 0) {
gravity.clock.SetTempo(gravity.clock.Tempo() + val);
} else if (selected_param == 1) {
reversed = (val < 0);
reversed = (dir == DIRECTION_DECREMENT);
}
}
@ -124,16 +111,8 @@ void UpdateDisplay() {
}
```
**Building 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
```
$ arduino-cli compile -v -b arduino:avr:nano ./firmware/Gravity/Gravity.ino -e --output-dir=./build/
```
```

View File

@ -17,7 +17,7 @@
* TODO: Store the calibration value in EEPROM.
*/
#include "libGravity.h"
#include "gravity.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(int val) {
void CalibrateCV(Direction dir, int val) {
AnalogInput* cv = (selected_param > 2) ? &gravity.cv2 : &gravity.cv1;
switch (selected_param % 3) {
case 0:

View File

@ -14,7 +14,7 @@
*
*/
#include "libGravity.h"
#include "gravity.h"
#define TEXT_FONT u8g2_font_profont11_tf
@ -39,7 +39,7 @@ void NextCalibrationPoint() {
selected_param = (selected_param + 1) % 2;
}
void CalibrateCV(int val) {
void CalibrateCV(Direction dir, int val) {
// AnalogInput* cv = (selected_param > 2) ? &gravity.cv2 : &gravity.cv1;
AnalogInput* cv = &gravity.cv1;
switch (selected_param % 2) {

View File

@ -1,4 +1,4 @@
#include "libGravity.h"
#include "gravity.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) ? Gravity::OUTPUT_COUNT - 1 : idx - 1;
idx = (idx == 0) ? OUTPUT_COUNT - 1 : idx - 1;
} else {
idx = (idx + 1) % Gravity::OUTPUT_COUNT;
idx = (idx + 1) % OUTPUT_COUNT;
}
gravity.outputs[idx].High();
}
}
void HandlePlayPressed() {
gravity.clock.Stop();
gravity.clock.Pause();
if (gravity.clock.IsPaused()) {
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
for (int i = 0; i < OUTPUT_COUNT; i++) {
gravity.outputs[i].Low();
}
}
}
void HandleRotate(int val) {
void HandleRotate(Direction dir, int val) {
if (selected_param == 0) {
gravity.clock.SetTempo(gravity.clock.Tempo() + val);
} else if (selected_param == 1) {
reversed = (val < 0);
reversed = (dir == DIRECTION_DECREMENT);
}
}
@ -80,7 +80,7 @@ void UpdateDisplay() {
gravity.display.print("Direction: ");
gravity.display.print((reversed) ? "Backward" : "Forward");
gravity.display.drawStr(0, selected_param * 10, "x");
gravity.display.drawChar(0, selected_param * 10, 0x10, 1, 0, 1);
gravity.display.display();
}

View File

@ -1,118 +0,0 @@
/**
* @file skeleton.ino
* @author YOUR_NAME (<url>)
* @brief Skeleton app for Sitka Instruments Gravity.
* @version vX.Y.Z - MONTH YEAR YOUR_NAME
* @date YYYY-MM-DD
*
* @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 <libGravity.h>
// 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.
//

View File

@ -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.0 - August 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
*
@ -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.
*
@ -66,6 +66,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);
@ -87,8 +91,18 @@ 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);
// 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);
@ -157,27 +171,6 @@ 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.
//
@ -209,42 +202,37 @@ void HandleEncoderPressed() {
// Check if leaving editing mode should apply a selection.
if (app.editing_param) {
if (app.selected_channel == 0) { // main page
switch (app.selected_param) {
case PARAM_MAIN_ENCODER_DIR:
app.encoder_reversed = app.selected_sub_param == 1;
gravity.encoder.SetReverseDirection(app.encoder_reversed);
break;
case PARAM_MAIN_ROTATE_DISP:
app.rotate_display = app.selected_sub_param == 1;
gravity.display.setFlipMode(app.rotate_display ? 1 : 0);
break;
case PARAM_MAIN_SAVE_DATA:
if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) {
app.selected_save_slot = app.selected_sub_param;
stateManager.saveData(app);
}
break;
case PARAM_MAIN_LOAD_DATA:
if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) {
app.selected_save_slot = app.selected_sub_param;
// Load pattern data into app state.
stateManager.loadData(app, app.selected_save_slot);
// Load global performance settings if they have changed.
if (gravity.clock.Tempo() != app.tempo) {
gravity.clock.SetTempo(app.tempo);
}
// Load global settings only clock is not active.
if (gravity.clock.IsPaused()) {
InitGravity(app);
}
}
break;
case PARAM_MAIN_FACTORY_RESET:
if (app.selected_sub_param == 0) { // Erase
stateManager.factoryReset(app);
InitGravity(app);
}
break;
// TODO: rewrite as switch
if (app.selected_param == PARAM_MAIN_ENCODER_DIR) {
app.encoder_reversed = app.selected_sub_param == 1;
gravity.encoder.SetReverseDirection(app.encoder_reversed);
}
if (app.selected_param == PARAM_MAIN_SAVE_DATA) {
if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) {
app.selected_save_slot = app.selected_sub_param;
stateManager.saveData(app);
}
}
if (app.selected_param == PARAM_MAIN_LOAD_DATA) {
if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) {
app.selected_save_slot = app.selected_sub_param;
stateManager.loadData(app, app.selected_save_slot);
InitGravity(app);
}
}
if (app.selected_param == PARAM_MAIN_RESET_STATE) {
if (app.selected_sub_param == 0) { // Reset
stateManager.reset(app);
InitGravity(app);
}
}
if (app.selected_param == PARAM_MAIN_FACTORY_RESET) {
if (app.selected_sub_param == 0) { // Erase
// Show bootsplash during slow erase operation.
Bootsplash();
stateManager.factoryReset(app);
InitGravity(app);
}
}
}
// Only mark dirty and reset selected_sub_param when leaving editing mode.
@ -294,14 +282,6 @@ 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<int>(app.selected_source);
updateSelection(source, val, Clock::SOURCE_LAST);
@ -318,15 +298,16 @@ void editMainParameter(int val) {
}
break;
}
// These changes are applied upon encoder button press.
case PARAM_MAIN_ENCODER_DIR:
case PARAM_MAIN_ROTATE_DISP:
updateSelection(app.selected_sub_param, val, 2);
break;
case PARAM_MAIN_SAVE_DATA:
case PARAM_MAIN_LOAD_DATA:
updateSelection(app.selected_sub_param, val, StateManager::MAX_SAVE_SLOTS + 1);
break;
case PARAM_MAIN_RESET_STATE:
updateSelection(app.selected_sub_param, val, 2);
break;
case PARAM_MAIN_FACTORY_RESET:
updateSelection(app.selected_sub_param, val, 2);
break;
@ -389,7 +370,6 @@ 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() {

View File

@ -2,8 +2,8 @@
* @file app_state.h
* @author Adam Wonak (https://github.com/awonak/)
* @brief Alt firmware version of Gravity by Sitka Instruments.
* @version 2.0.0
* @date 2025-08-17
* @version 2.0.1
* @date 2025-07-04
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
@ -25,13 +25,10 @@ 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;
bool encoder_reversed = false;
bool rotate_display = false;
bool refresh_screen = true;
};

View File

@ -2,8 +2,8 @@
* @file channel.h
* @author Adam Wonak (https://github.com/awonak/)
* @brief Alt firmware version of Gravity by Sitka Instruments.
* @version 2.0.0
* @date 2025-08-17
* @version 2.0.1
* @date 2025-07-04
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
@ -70,6 +70,14 @@ 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;
@ -80,104 +88,78 @@ 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);
_recalculatePulses();
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);
_recalculatePulses();
if (!isCvModActive()) {
cvmod_duty_cycle = base_duty_cycle;
_recalculatePulses();
}
}
void setOffset(int off) {
base_offset = constrain(off, 0, 99);
_recalculatePulses();
if (!isCvModActive()) {
cvmod_offset = base_offset;
_recalculatePulses();
}
}
void setSwing(int val) {
base_swing = constrain(val, 50, 95);
_recalculatePulses();
if (!isCvModActive()) {
cvmod_swing = base_swing;
_recalculatePulses();
}
}
// Euclidean
void setSteps(int val) {
pattern.SetSteps(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) {
pattern.SetHits(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;
_recalculatePulses();
}
void setCv2Dest(CvDestination dest) {
cv2_dest = dest;
_recalculatePulses();
}
void setCv1Dest(CvDestination dest) { cv1_dest = dest; }
void setCv2Dest(CvDestination dest) { cv2_dest = dest; }
CvDestination getCv1Dest() const { return cv1_dest; }
CvDestination getCv2Dest() const { return cv2_dest; }
// Getters (Get the BASE value for editing or cv modded value for display)
int getProbability() 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(); }
// 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);
}
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; }
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);
}
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; }
@ -194,13 +176,6 @@ 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.
@ -236,6 +211,56 @@ 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, 100);
int prob_mod = _calculateMod(CV_DEST_PROB, cv1_val, cv2_val, -50, 50);
cvmod_probability = constrain(base_probability + prob_mod, 0, 100);
int duty_mod = _calculateMod(CV_DEST_DUTY, cv1_val, cv2_val, -50, 50);
cvmod_duty_cycle = constrain(base_duty_cycle + duty_mod, 1, 99);
int offset_mod = _calculateMod(CV_DEST_OFFSET, cv1_val, cv2_val, -50, 50);
cvmod_offset = constrain(base_offset + offset_mod, 0, 99);
int swing_mod = _calculateMod(CV_DEST_SWING, cv1_val, cv2_val, -25, 25);
cvmod_swing = constrain(base_swing + swing_mod, 50, 95);
int step_mod = _calculateMod(CV_DEST_EUC_STEPS, cv1_val, cv2_val, 0, MAX_PATTERN_LEN);
pattern.SetSteps(base_euc_steps + step_mod);
int hit_mod = _calculateMod(CV_DEST_EUC_HITS, cv1_val, cv2_val, 0, pattern.GetSteps());
pattern.SetHits(base_euc_hits + hit_mod);
// After all cvmod values are updated, recalculate clock pulse modifiers.
_recalculatePulses();
}
private:
int _calculateMod(CvDestination dest, int cv1_val, int cv2_val, int min_range, int max_range) {
@ -245,19 +270,13 @@ class Channel {
}
void _recalculatePulses() {
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);
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 (swing > 50) {
int shifted_swing = swing - 50;
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;
@ -270,6 +289,15 @@ 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;

View File

@ -2,8 +2,8 @@
* @file display.h
* @author Adam Wonak (https://github.com/awonak/)
* @brief Alt firmware version of Gravity by Sitka Instruments.
* @version 2.0.0
* @date 2025-08-17
* @version 2.0.1
* @date 2025-07-04
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
@ -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") PROGMEM =
const uint8_t LARGE_FONT[766] U8G2_FONT_SECTION("stk-l") =
"\35\0\4\4\4\5\3\1\6\20\30\0\0\27\0\0\0\1\77\0\0\2\341%'\17;\226\261\245FL"
"\64B\214\30\22\223\220)Bj\10Q\232\214\42R\206\310\210\21d\304\30\32a\254\304\270!\0/\14"
"\272\272\275\311H\321g\343\306\1\60\37|\373\35CJT\20:fW\207\320\210\60\42\304\204\30D\247"
@ -100,13 +100,11 @@ 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_ROTATE_DISP,
PARAM_MAIN_SAVE_DATA,
PARAM_MAIN_LOAD_DATA,
PARAM_MAIN_RESET_STATE,
PARAM_MAIN_FACTORY_RESET,
PARAM_MAIN_LAST,
};
@ -257,42 +255,11 @@ void DisplayMainPage() {
case Clock::SOURCE_EXTERNAL_PPQN_4:
subText = F("4 PPQN");
break;
case Clock::SOURCE_EXTERNAL_PPQN_1:
subText = F("1 PPQN");
break;
case Clock::SOURCE_EXTERNAL_MIDI:
subText = F("MIDI");
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) {
@ -314,10 +281,6 @@ 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) {
@ -334,6 +297,15 @@ 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");
@ -349,7 +321,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("ROTATE DISP"), F("SAVE"), F("LOAD"), F("ERASE")};
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);
}
@ -366,12 +338,10 @@ 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 = withCvMod ? ch.getClockModWithMod(cv1, cv2) : ch.getClockMod();
int mod_value = ch.getClockMod(withCvMod);
if (mod_value > 1) {
mainText = F("/");
mainText += String(mod_value);
@ -384,30 +354,30 @@ void DisplayChannelPage() {
break;
}
case PARAM_CH_PROB:
mainText = String(withCvMod ? ch.getProbabilityWithMod(cv1, cv2) : ch.getProbability()) + F("%");
mainText = String(ch.getProbability(withCvMod)) + F("%");
subText = F("HIT CHANCE");
break;
case PARAM_CH_DUTY:
mainText = String(withCvMod ? ch.getDutyCycleWithMod(cv1, cv2) : ch.getDutyCycle()) + F("%");
mainText = String(ch.getDutyCycle(withCvMod)) + F("%");
subText = F("PULSE WIDTH");
break;
case PARAM_CH_OFFSET:
mainText = String(withCvMod ? ch.getOffsetWithMod(cv1, cv2) : ch.getOffset()) + F("%");
mainText = String(ch.getOffset(withCvMod)) + F("%");
subText = F("SHIFT HIT");
break;
case PARAM_CH_SWING:
ch.getSwing() == 50
? mainText = F("OFF")
: mainText = String(withCvMod ? ch.getSwingWithMod(cv1, cv2) : ch.getSwing()) + F("%");
: mainText = String(ch.getSwing(withCvMod)) + F("%");
subText = "DOWN BEAT";
swingDivisionMark();
break;
case PARAM_CH_EUC_STEPS:
mainText = String(withCvMod ? ch.getStepsWithMod(cv1, cv2) : ch.getSteps());
mainText = String(ch.getSteps(withCvMod));
subText = "EUCLID STEPS";
break;
case PARAM_CH_EUC_HITS:
mainText = String(withCvMod ? ch.getHitsWithMod(cv1, cv2) : ch.getHits());
mainText = String(ch.getHits(withCvMod));
subText = "EUCLID HITS";
break;
case PARAM_CH_CV1_DEST:
@ -495,7 +465,25 @@ void UpdateDisplay() {
DisplayChannelPage();
}
// Global channel select UI.
DisplaySelectedChannel();
DisplaySelectedChannel();
} while (gravity.display.nextPage());
}
void Bootsplash() {
gravity.display.firstPage();
do {
int textWidth;
String loadingText = F("LOADING....");
gravity.display.setFont(TEXT_FONT);
textWidth = gravity.display.getStrWidth(StateManager::SKETCH_NAME);
gravity.display.drawStr(16 + (textWidth / 2), 20, StateManager::SKETCH_NAME);
textWidth = gravity.display.getStrWidth(StateManager::SEMANTIC_VERSION);
gravity.display.drawStr(16 + (textWidth / 2), 32, StateManager::SEMANTIC_VERSION);
textWidth = gravity.display.getStrWidth(loadingText.c_str());
gravity.display.drawStr(26 + (textWidth / 2), 44, loadingText.c_str());
} while (gravity.display.nextPage());
}

View File

@ -2,8 +2,8 @@
* @file euclidean.h
* @author Adam Wonak (https://github.com/awonak/)
* @brief Alt firmware version of Gravity by Sitka Instruments.
* @version 2.0.0
* @date 2025-08-17
* @version 2.0.1
* @date 2025-07-04
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*

View File

@ -2,8 +2,8 @@
* @file save_state.cpp
* @author Adam Wonak (https://github.com/awonak/)
* @brief Alt firmware version of Gravity by Sitka Instruments.
* @version 2.0.0
* @date 2025-08-17
* @version 2.0.1
* @date 2025-07-04
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
@ -17,7 +17,7 @@
// Define the constants for the current firmware.
const char StateManager::SKETCH_NAME[] = "ALT GRAVITY";
const char StateManager::SEMANTIC_VERSION[] = "2.0.0"; // 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;
@ -33,74 +33,60 @@ const int StateManager::EEPROM_DATA_START_ADDR = sizeof(StateManager::Metadata);
StateManager::StateManager() : _isDirty(false), _lastChangeTime(0) {}
bool StateManager::initialize(AppState& app) {
noInterrupts();
bool success = false;
if (_isDataValid()) {
// Load global settings.
_loadMetadata(app);
// Load app data from the transient slot.
_loadState(app, TRANSIENT_SLOT);
success = true;
return true;
}
// EEPROM does not contain save data for this firmware & version.
else {
// Erase EEPROM and initialize state. Save default pattern to all save slots.
factoryReset(app);
return false;
}
interrupts();
return success;
}
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;
noInterrupts();
// Load the state data from the specified EEPROM slot and update the app state save slot.
_loadState(app, slot_index);
app.selected_save_slot = slot_index;
// Persist this change in the global metadata on next update.
_isDirty = true;
// Persist this change in the global metadata.
_saveMetadata(app);
interrupts();
return true;
}
// Save app state to user specified save slot.
void StateManager::saveData(const AppState& app) {
noInterrupts();
// Check if slot_index is within max range + 1 for transient.
if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) return;
_saveState(app, app.selected_save_slot);
_saveMetadata(app);
_isDirty = false;
interrupts();
}
// Save transient state if it has changed and enough time has passed since last save.
void StateManager::update(const AppState& app) {
if (_isDirty && (millis() - _lastChangeTime > SAVE_DELAY_MS)) {
noInterrupts();
_saveState(app, TRANSIENT_SLOT);
_saveMetadata(app);
_isDirty = false;
interrupts();
}
}
void StateManager::reset(AppState& app) {
noInterrupts();
AppState default_app;
app.tempo = default_app.tempo;
app.selected_param = default_app.selected_param;
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();
@ -110,7 +96,6 @@ void StateManager::reset(AppState& app) {
_loadMetadata(app);
_isDirty = false;
interrupts();
}
void StateManager::markDirty() {
@ -147,6 +132,7 @@ void StateManager::_saveState(const AppState& app, byte slot_index) {
// Check if slot_index is within max range + 1 for transient.
if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1) return;
noInterrupts();
static EepromData save_data;
save_data.tempo = app.tempo;
@ -154,8 +140,6 @@ void StateManager::_saveState(const AppState& app, byte slot_index) {
save_data.selected_channel = app.selected_channel;
save_data.selected_source = static_cast<byte>(app.selected_source);
save_data.selected_pulse = static_cast<byte>(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
@ -164,25 +148,27 @@ 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();
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.base_clock_mod_index = ch.getClockModIndex(false);
save_ch.base_probability = ch.getProbability(false);
save_ch.base_duty_cycle = ch.getDutyCycle(false);
save_ch.base_offset = ch.getOffset(false);
save_ch.base_swing = ch.getSwing(false);
save_ch.base_euc_steps = ch.getSteps(false);
save_ch.base_euc_hits = ch.getHits(false);
save_ch.cv1_dest = static_cast<byte>(ch.getCv1Dest());
save_ch.cv2_dest = static_cast<byte>(ch.getCv2Dest());
}
int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData));
EEPROM.put(address, save_data);
interrupts();
}
void StateManager::_loadState(AppState& app, byte slot_index) {
// Check if slot_index is within max range + 1 for transient.
if (slot_index >= MAX_SAVE_SLOTS + 1) return;
noInterrupts();
static EepromData load_data;
int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData));
EEPROM.get(address, load_data);
@ -193,8 +179,6 @@ void StateManager::_loadState(AppState& app, byte slot_index) {
app.selected_channel = load_data.selected_channel;
app.selected_source = static_cast<Clock::Source>(load_data.selected_source);
app.selected_pulse = static_cast<Clock::Pulse>(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];
@ -210,9 +194,11 @@ void StateManager::_loadState(AppState& app, byte slot_index) {
ch.setCv1Dest(static_cast<CvDestination>(saved_ch_state.cv1_dest));
ch.setCv2Dest(static_cast<CvDestination>(saved_ch_state.cv2_dest));
}
interrupts();
}
void StateManager::_saveMetadata(const AppState& app) {
noInterrupts();
Metadata current_meta;
strcpy(current_meta.sketch_name, SKETCH_NAME);
strcpy(current_meta.version, SEMANTIC_VERSION);
@ -220,15 +206,16 @@ 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();
}
void StateManager::_loadMetadata(AppState& app) {
noInterrupts();
Metadata metadata;
EEPROM.get(METADATA_START_ADDR, metadata);
app.selected_save_slot = metadata.selected_save_slot;
app.encoder_reversed = metadata.encoder_reversed;
app.rotate_display = metadata.rotate_display;
interrupts();
}

View File

@ -2,8 +2,8 @@
* @file save_state.h
* @author Adam Wonak (https://github.com/awonak/)
* @brief Alt firmware version of Gravity by Sitka Instruments.
* @version 2.0.0
* @date 2025-08-17
* @version 2.0.1
* @date 2025-07-04
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
@ -57,7 +57,6 @@ class StateManager {
// Additional global/hardware settings
byte selected_save_slot;
bool encoder_reversed;
bool rotate_display;
};
struct ChannelState {
byte base_clock_mod_index;
@ -77,8 +76,6 @@ class StateManager {
byte selected_channel;
byte selected_source;
byte selected_pulse;
byte cv_run;
byte cv_reset;
ChannelState channel_data[Gravity::OUTPUT_COUNT];
};

View File

@ -1,5 +1,5 @@
name=libGravity
version=2.0.1
version=2.0.0beta3
author=Adam Wonak
maintainer=awonak <github.com/awonak>
sentence=Hardware abstraction library for Sitka Instruments Gravity eurorack module
@ -7,4 +7,4 @@ category=Other
license=MIT
url=https://github.com/awonak/libGravity
architectures=avr
depends=uClock,RotaryEncoder,U8g2,NeoHWSerial
depends=uClock,RotaryEncoder,U8g2

343
src/README.md Normal file
View File

@ -0,0 +1,343 @@
# libGravity API Reference
This document provides API documentation for `libGravity`, a library for building custom scripts for the Sitka Instruments Gravity module.
## `Gravity` Class
The `Gravity` class is the main hardware abstraction wrapper for the module. It provides a central point of access to all of the module's hardware components like the display, clock, inputs, and outputs.
A global instance of this class, `gravity`, is created for you to use in your scripts.
```cpp
// Global instance
extern Gravity gravity;
```
### Public Methods
#### `void Init()`
Initializes the Arduino and all the Gravity hardware components. This should be called once in your `setup()` function.
#### `void Process()`
Performs a polling check for state changes on all inputs and outputs. This should be called repeatedly in your main `loop()` function to ensure all components are responsive.
### Public Properties
* `U8G2_SSD1306_128X64_NONAME_1_HW_I2C display`
* OLED display object from the `U8g2lib` library. Use this to draw to the screen.
* `Clock clock`
* The main clock source wrapper. See the [Clock Class](https://www.google.com/search?q=%23clock-class) documentation for details.
* `DigitalOutput outputs[OUTPUT_COUNT]`
* An array of `DigitalOutput` objects, where `OUTPUT_COUNT` is 6. Each element corresponds to one of the six gate/trigger outputs.
* `DigitalOutput pulse`
* A `DigitalOutput` object for the MIDI Expander module's pulse output.
* `Encoder encoder`
* The rotary encoder with a built-in push button. See the [Encoder Class](https://www.google.com/search?q=%23encoder-class) documentation for details.
* `Button shift_button`
* A `Button` object for the 'Shift' button.
* `Button play_button`
* A `Button` object for the 'Play' button.
* `AnalogInput cv1`
* An `AnalogInput` object for the CV1 input jack.
* `AnalogInput cv2`
* An `AnalogInput` object for the CV2 input jack.
## `AnalogInput` Class
This class handles reading and processing the analog CV inputs. It includes features for calibration, offsetting, and attenuation.
### Public Methods
#### `void Init(uint8_t pin)`
Initializes the analog input on a specific pin.
* **Parameters:**
* `pin`: The GPIO pin for the analog input.
#### `void Process()`
Reads the raw value from the ADC, applies calibration, offset, and attenuation/inversion. This must be called regularly in the main loop.
#### `void AdjustCalibrationLow(int amount)`
Adjusts the low calibration point to fine-tune the input mapping.
* **Parameters:**
* `amount`: The amount to add to the current low calibration value.
#### `void AdjustCalibrationHigh(int amount)`
Adjusts the high calibration point to fine-tune the input mapping.
* **Parameters:**
* `amount`: The amount to add to the current high calibration value.
#### `void SetOffset(float percent)`
Sets a DC offset for the input signal.
* **Parameters:**
* `percent`: A percentage (e.g., `0.5` for 50%) to shift the signal.
#### `void SetAttenuation(float percent)`
Sets the attenuation (scaling) of the input signal. A negative percentage will also invert the signal.
* **Parameters:**
* `percent`: The attenuation level, typically from `0.0` to `1.0`.
#### `int16_t Read()`
Gets the current processed value of the analog input.
* **Returns:** The read value, scaled to a range of +/-512.
#### `float Voltage()`
Gets the analog read value as a voltage.
* **Returns:** A `float` representing the calculated voltage (-5.0V to +5.0V).
## `Button` Class
A wrapper class for handling digital inputs like push buttons, including debouncing and long-press detection.
### Enums
#### `enum ButtonChange`
Constants representing a change in the button's state.
* `CHANGE_UNCHANGED`
* `CHANGE_PRESSED`
* `CHANGE_RELEASED` (a normal, short press)
* `CHANGE_RELEASED_LONG` (a long press)
### Public Methods
#### `void Init(uint8_t pin)`
Initializes the button on a specific GPIO pin.
* **Parameters:**
* `pin`: The GPIO pin for the button.
#### `void AttachPressHandler(void (*f)())`
Attaches a callback function to be executed on a short button press.
* **Parameters:**
* `f`: The function to call.
#### `void AttachLongPressHandler(void (*f)())`
Attaches a callback function to be executed on a long button press.
* **Parameters:**
* `f`: The function to call.
#### `void Process()`
Reads the button's state and handles debouncing and press detection. Call this repeatedly in the main loop.
#### `ButtonChange Change()`
Gets the last state change of the button.
* **Returns:** A `ButtonChange` enum value indicating the last detected change.
#### `bool On()`
Checks the current physical state of the button.
* **Returns:** `true` if the button is currently being held down, `false` otherwise.
## `Clock` Class
A wrapper for all clock and timing functions, supporting internal, external, and MIDI clock sources.
### Enums
#### `enum Source`
Defines the possible clock sources.
* `SOURCE_INTERNAL`
* `SOURCE_EXTERNAL_PPQN_24` (24 pulses per quarter note)
* `SOURCE_EXTERNAL_PPQN_4` (4 pulses per quarter note)
* `SOURCE_EXTERNAL_MIDI`
#### `enum Pulse`
Defines the possible pulse-per-quarter-note rates for the pulse output.
* `PULSE_NONE`
* `PULSE_PPQN_1`
* `PULSE_PPQN_4`
* `PULSE_PPQN_24`
### Public Methods
#### `void Init()`
Initializes the clock, sets up MIDI serial, and sets default values.
#### `void AttachExtHandler(void (*callback)())`
Attaches a user-defined callback to the external clock input. This is triggered by a rising edge on the external clock pin or by an incoming MIDI clock message.
* **Parameters:**
* `callback`: The function to call on an external clock event.
#### `void AttachIntHandler(void (*callback)(uint32_t))`
Sets a callback function that is triggered at the high-resolution internal clock rate (PPQN\_96). This is the main internal timing callback.
* **Parameters:**
* `callback`: The function to call on every internal clock tick. It receives the tick count as a `uint32_t` parameter.
#### `void SetSource(Source source)`
Sets the clock's driving source.
* **Parameters:**
* `source`: The new clock source from the `Source` enum.
#### `bool ExternalSource()`
Checks if the clock source is external.
* **Returns:** `true` if the source is external (PPQN or MIDI).
#### `bool InternalSource()`
Checks if the clock source is internal.
* **Returns:** `true` if the source is the internal master clock.
#### `int Tempo()`
Gets the current tempo.
* **Returns:** The current tempo in beats per minute (BPM).
#### `void SetTempo(int tempo)`
Sets the clock tempo when in internal mode.
* **Parameters:**
* `tempo`: The new tempo in BPM.
#### `void Tick()`
Manually triggers a clock tick. This should be called from your external clock handler to drive the internal timing when in an external clock mode.
#### `void Start()`
Starts the clock.
#### `void Stop()`
Stops (pauses) the clock.
#### `void Reset()`
Resets all clock counters to zero.
#### `bool IsPaused()`
Checks if the clock is currently paused.
* **Returns:** `true` if the clock is stopped.
## `DigitalOutput` Class
This class is used to control the digital gate/trigger outputs.
### Public Methods
#### `void Init(uint8_t cv_pin)`
Initializes a digital output on a specific pin.
* **Parameters:**
* `cv_pin`: The GPIO pin for the CV/Gate output.
#### `void SetTriggerDuration(uint8_t duration_ms)`
Sets the duration for triggers. When `Trigger()` is called, the output will remain high for this duration.
* **Parameters:**
* `duration_ms`: The trigger duration in milliseconds.
#### `void Update(uint8_t state)`
Sets the output state directly.
* **Parameters:**
* `state`: `HIGH` or `LOW`.
#### `void High()`
Sets the output to HIGH (approx. 5V).
#### `void Low()`
Sets the output to LOW (0V).
#### `void Trigger()`
Begins a trigger. The output goes HIGH and will automatically be set LOW after the configured trigger duration has elapsed (handled by `Process()`).
#### `void Process()`
Handles the timing for triggers. If an output was triggered, this method checks if the duration has elapsed and sets the output LOW if necessary. Call this in the main loop.
#### `bool On()`
Returns the current on/off state of the output.
* **Returns:** `true` if the output is currently HIGH.
## `Encoder` Class
Handles all interaction with the rotary encoder, including rotation, button presses, and rotation while pressed.
**Header:** `encoder_dir.h`
### Public Methods
#### `void SetReverseDirection(bool reversed)`
Sets the direction of the encoder.
* **Parameters:**
* `reversed`: Set to `true` to reverse the direction of rotation.
#### `void AttachPressHandler(void (*f)())`
Attaches a callback for a simple press-and-release of the encoder button.
* **Parameters:**
* `f`: The function to call on a button press.
#### `void AttachRotateHandler(void (*f)(int val))`
Attaches a callback for when the encoder is rotated (while the button is not pressed).
* **Parameters:**
* `f`: The callback function. It receives an `int` representing the change in position (can be positive or negative).
#### `void AttachPressRotateHandler(void (*f)(int val))`
Attaches a callback for when the encoder is rotated while the button is being held down.
* **Parameters:**
* `f`: The callback function. It receives an `int` representing the change in position.
#### `void Process()`
Processes encoder and button events. This method must be called repeatedly in the main loop to check for state changes and dispatch the appropriate callbacks.

View File

@ -2,8 +2,8 @@
* @file analog_input.h
* @author Adam Wonak (https://github.com/awonak)
* @brief Class for interacting with analog inputs.
* @version 2.0.0
* @date 2025-08-17
* @version 0.1
* @date 2025-05-23
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
@ -13,21 +13,23 @@
const int MAX_INPUT = (1 << 10) - 1; // Max 10 bit analog read resolution.
// estimated default calibration value
// Estimated default calibration value
// TODO: This should be set by metadata via calibration.
const int CALIBRATED_LOW = -566;
const int CALIBRATED_HIGH = 512;
/**
* @brief Class for interacting with analog inputs (CV).
*/
class AnalogInput {
public:
static const int GATE_THRESHOLD = 0;
AnalogInput() {}
~AnalogInput() {}
/**
* Initializes a analog input object.
* @brief Initializes an analog input object.
*
* @param pin gpio pin for the analog input.
* @param pin The GPIO pin for the analog input.
*/
void Init(uint8_t pin) {
pinMode(pin, INPUT);
@ -35,8 +37,11 @@ class AnalogInput {
}
/**
* Read the value of the analog input and set instance state.
* @brief Reads and processes the analog input.
*
* This method reads the raw value from the ADC, applies the current
* calibration, offset, and attenuation/inversion settings. It should be
* called regularly in the main loop to update the input's state.
*/
void Process() {
old_read_ = read_;
@ -46,14 +51,38 @@ class AnalogInput {
if (inverted_) read_ = -read_;
}
// Set calibration values.
/**
* @brief Adjusts the low calibration point.
*
* This is used to fine-tune the mapping of the raw analog input to the output range.
*
* @param amount The amount to add to the current low calibration value.
*/
void AdjustCalibrationLow(int amount) { low_ += amount; }
/**
* @brief Adjusts the high calibration point.
*
* This is used to fine-tune the mapping of the raw analog input to the output range.
*
* @param amount The amount to add to the current high calibration value.
*/
void AdjustCalibrationHigh(int amount) { high_ += amount; }
/**
* @brief Sets a DC offset for the input.
*
* @param percent A percentage (e.g., 0.5 for 50%) to shift the signal.
*/
void SetOffset(float percent) { offset_ = -(percent)*512; }
/**
* @brief Sets the attenuation (scaling) of the input signal.
*
* This scales the input signal. A negative percentage will also invert the signal.
*
* @param percent The attenuation level, typically from 0.0 to 1.0.
*/
void SetAttenuation(float percent) {
low_ = abs(percent) * CALIBRATED_LOW;
high_ = abs(percent) * CALIBRATED_HIGH;
@ -61,33 +90,19 @@ class AnalogInput {
}
/**
* Get the current value of the analog input within a range of +/-512.
*
* @return read value within a range of +/-512.
* @brief Get the current processed value of the analog input.
*
* @return The 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).
* @brief Return the analog read value as a voltage.
*
* @return A float representing the calculated voltage (-5.0 to +5.0).
*/
inline float Voltage() { return ((read_ / 512.0) * 5.0); }
/**
* Checks for a rising edge transition across a threshold.
*
* @param threshold The value that the input must cross.
* @return True if the value just crossed the threshold from below, false otherwise.
*/
inline bool IsRisingEdge(int16_t threshold) const {
bool was_high = old_read_ > threshold;
bool is_high = read_ > threshold;
return is_high && !was_high;
}
private:
uint8_t pin_;
int16_t read_;
@ -99,4 +114,4 @@ class AnalogInput {
bool inverted_ = false;
};
#endif
#endif

View File

@ -2,9 +2,9 @@
* @file button.h
* @author Adam Wonak (https://github.com/awonak)
* @brief Wrapper class for interacting with trigger / gate inputs.
* @version 2.0.0
* @date 2025-08-17
*
* @version 0.1
* @date 2025-04-20
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
*/
@ -13,14 +13,14 @@
#include <Arduino.h>
const uint8_t DEBOUNCE_MS = 10;
const uint16_t LONG_PRESS_DURATION_MS = 750;
class Button {
protected:
typedef void (*CallbackFunction)(void);
public:
static const uint8_t DEBOUNCE_MS = 10;
static const uint16_t LONG_PRESS_DURATION_MS = 750;
// Enum constants for active change in button state.
enum ButtonChange {
CHANGE_UNCHANGED,
@ -84,7 +84,7 @@ class Button {
if (on_long_press_ != NULL) on_long_press_();
}
}
// Update variables for next loop
last_press_ = (pressed || released) ? millis() : last_press_;
old_read_ = read;

View File

@ -2,9 +2,9 @@
* @file clock.h
* @author Adam Wonak (https://github.com/awonak)
* @brief Wrapper Class for clock timing functions.
* @version 2.0.0
* @date 2025-08-17
*
* @version 0.1
* @date 2025-05-04
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
*/
@ -27,6 +27,9 @@ typedef void (*ExtCallback)(void);
static ExtCallback extUserCallback = nullptr;
static void serialEventNoop(uint8_t msg, uint8_t status) {}
/**
* @brief Wrapper Class for clock timing functions.
*/
class Clock {
public:
static constexpr int DEFAULT_TEMPO = 120;
@ -35,19 +38,21 @@ class Clock {
SOURCE_INTERNAL,
SOURCE_EXTERNAL_PPQN_24,
SOURCE_EXTERNAL_PPQN_4,
SOURCE_EXTERNAL_PPQN_1,
SOURCE_EXTERNAL_MIDI,
SOURCE_LAST,
};
enum Pulse {
PULSE_NONE,
PULSE_PPQN_24,
PULSE_PPQN_4,
PULSE_PPQN_1,
PULSE_PPQN_4,
PULSE_PPQN_24,
PULSE_LAST,
};
/**
* @brief Initializes the clock, MIDI serial, and sets default values.
*/
void Init() {
NeoSerial.begin(31250);
@ -65,18 +70,36 @@ class Clock {
uClock.start();
}
// Handle external clock tick and call user callback when receiving clock trigger (PPQN_4, PPQN_24, or MIDI).
/**
* @brief Attach a handler for external clock ticks.
*
* This function attaches a user-defined callback to the external clock input pin interrupt.
* It is also called for incoming MIDI clock events.
*
* @param callback Function to call on an external clock event.
*/
void AttachExtHandler(void (*callback)()) {
extUserCallback = callback;
attachInterrupt(digitalPinToInterrupt(EXT_PIN), callback, RISING);
}
// Internal PPQN96 callback for all clock timer operations.
/**
* @brief Attach a handler for the internal high-resolution clock.
*
* Sets a callback function that is triggered at the internal PPQN_96 rate. This is the
* main internal timing callback for all clock operations.
*
* @param callback Function to call on every internal clock tick. It receives the tick count as a parameter.
*/
void AttachIntHandler(void (*callback)(uint32_t)) {
uClock.setOnOutputPPQN(callback);
}
// Set the source of the clock mode.
/**
* @brief Set the source of the clock.
*
* @param source The new source for driving the clock. See the `Source` enum.
*/
void SetSource(Source source) {
bool was_playing = !IsPaused();
uClock.stop();
@ -97,10 +120,6 @@ class Clock {
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
uClock.setInputPPQN(uClock.PPQN_4);
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);
@ -112,47 +131,81 @@ class Clock {
}
}
// Return true if the current selected source is externl (PPQN_4, PPQN_24, or MIDI).
/**
* @brief Checks if the clock source is external.
*
* @return true if the current source is external (PPQN_4, PPQN_24, or MIDI).
* @return false if the source is internal.
*/
bool ExternalSource() {
return uClock.getClockMode() == uClock.EXTERNAL_CLOCK;
}
// Return true if the current selected source is the internal master clock.
/**
* @brief Checks if the clock source is internal.
*
* @return true if the current source is the internal master clock.
* @return false if the source is external.
*/
bool InternalSource() {
return uClock.getClockMode() == uClock.INTERNAL_CLOCK;
}
// Returns the current BPM tempo.
/**
* @brief Gets the current tempo.
*
* @return int The current tempo in beats per minute (BPM).
*/
int Tempo() {
return uClock.getTempo();
}
// Set the clock tempo to a int between 1 and 400.
/**
* @brief Set the clock tempo.
*
* @param tempo The new tempo in beats per minute (BPM).
*/
void SetTempo(int tempo) {
return uClock.setTempo(tempo);
}
// Record an external clock tick received to process external/internal syncronization.
/**
* @brief Manually trigger a clock tick.
*
* This should be called when in an external clock mode to register an incoming
* clock pulse and drive the internal timing.
*/
void Tick() {
uClock.clockMe();
}
// Start the internal clock.
/**
* @brief Starts the clock.
*/
void Start() {
uClock.start();
}
// Stop internal clock clock.
/**
* @brief Stops (pauses) the clock.
*/
void Stop() {
uClock.stop();
}
// Reset all clock counters to 0.
/**
* @brief Resets all clock counters to zero.
*/
void Reset() {
uClock.resetCounters();
}
// Returns true if the clock is not running.
/**
* @brief Checks if the clock is currently paused.
*
* @return true if the clock is stopped/paused.
* @return false if the clock is running.
*/
bool IsPaused() {
return uClock.clock_state == uClock.PAUSED;
}

View File

@ -2,8 +2,8 @@
* @file digital_output.h
* @author Adam Wonak (https://github.com/awonak)
* @brief Class for interacting with trigger / gate outputs.
* @version 2.0.0
* @date 2025-08-17
* @version 0.1
* @date 2025-04-17
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
@ -13,10 +13,10 @@
#include <Arduino.h>
const byte DEFAULT_TRIGGER_DURATION_MS = 5;
class DigitalOutput {
public:
static const byte DEFAULT_TRIGGER_DURATION_MS = 5;
/**
* Initializes an CV Output paired object.
*
@ -82,6 +82,7 @@ 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) {

View File

@ -2,12 +2,13 @@
* @file encoder_dir.h
* @author Adam Wonak (https://github.com/awonak)
* @brief Class for interacting with encoders.
* @version 2.0.0
* @date 2025-08-17
*
* @version 0.1
* @date 2025-04-19
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
*/
#ifndef ENCODER_DIR_H
#define ENCODER_DIR_H
@ -16,6 +17,9 @@
#include "button.h"
#include "peripherials.h"
/**
* @brief Class for interacting with a rotary encoder that has a push button.
*/
class Encoder {
protected:
typedef void (*CallbackFunction)(void);
@ -32,22 +36,57 @@ class Encoder {
}
~Encoder() {}
// Set to true if the encoder read direction should be reversed.
/**
* @brief Set the direction of the encoder.
*
* @param reversed Set to true to reverse the direction of rotation.
*/
void SetReverseDirection(bool reversed) {
reversed_ = reversed;
}
/**
* @brief Attach a handler for the encoder button press.
*
* This callback is triggered on a simple press and release of the button,
* without any rotation occurring during the press.
*
* @param f The callback function to execute when a button press.
*/
void AttachPressHandler(CallbackFunction f) {
on_press = f;
}
/**
* @brief Attach a handler for encoder rotation.
*
* This callback is triggered when the encoder is rotated while the button is not pressed.
*
* @param f The callback function to execute on rotation. It receives an integer
* representing the change in position (can be positive or negative).
*/
void AttachRotateHandler(RotateCallbackFunction f) {
on_rotate = f;
}
/**
* @brief Attach a handler for rotation while the button is pressed.
*
* This callback is triggered when the encoder is rotated while the button is being held down.
*
* @param f The callback function to execute. It receives an integer
* representing the change in position.
*/
void AttachPressRotateHandler(RotateCallbackFunction f) {
on_press_rotate = f;
}
/**
* @brief Processes encoder and button events.
*
* This method should be called repeatedly in the main loop to check for state
* changes (rotation, button presses) and dispatch the appropriate callbacks.
*/
void Process() {
// Get encoder position change amount.
int encoder_rotated = _rotate_change() != 0;
@ -91,7 +130,6 @@ class Encoder {
int position = encoder_.getPosition();
unsigned long ms = encoder_.getMillisBetweenRotations();
// Validation (TODO: add debounce check).
if (previous_pos_ == position) {
return 0;
}

View File

@ -2,8 +2,8 @@
* @file libGravity.cpp
* @author Adam Wonak (https://github.com/awonak)
* @brief Library for building custom scripts for the Sitka Instruments Gravity module.
* @version 2.0.0
* @date 2025-08-17
* @version 0.1
* @date 2025-04-19
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*

View File

@ -2,8 +2,8 @@
* @file libGravity.h
* @author Adam Wonak (https://github.com/awonak)
* @brief Library for building custom scripts for the Sitka Instruments Gravity module.
* @version 2.0.0
* @date 2025-08-17
* @version 0.1
* @date 2025-04-19
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*

View File

@ -2,8 +2,8 @@
* @file peripherials.h
* @author Adam Wonak (https://github.com/awonak)
* @brief Arduino pin definitions for the Sitka Instruments Gravity module.
* @version 2.0.0
* @date 2025-08-17
* @version 0.1
* @date 2025-04-19
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*