35 Commits

Author SHA1 Message Date
fbf8bd94c6 Rollback to v2.0.0beta3 - reverting changes from v2.0.0 2026-03-07 20:09:57 -08:00
acd028846c Full repo version bump to v2.0.0 2025-08-17 11:04:25 -07:00
ed625e75fc Merge pull request 'Fix Save/Load State' (#28) from fix-save-state into main
Reviewed-on: #28
2025-08-17 17:18:56 +00:00
b60dcc0e68 one more 2025-08-16 11:00:31 -07:00
909d589609 make version consistent 2025-08-16 10:58:42 -07:00
330f5e6ceb improve docstring comments 2025-08-16 10:47:06 -07:00
87dacd869b improve the usage of disabling interrupts to avoid a potential race condition with isr being called between private method execution. 2025-08-16 10:06:11 -07:00
64f467d6ac Add missing metadata field in _loadMetadata 2025-08-16 09:55:43 -07:00
84cafe2387 Fix bug in metadata save/load state.
The sketch_name char array was to short, causing a buffer overflow.
2025-08-16 09:51:05 -07:00
8bb89a5f4b formatting 2025-08-14 07:31:43 -07:00
499bc7a643 Added more details explaining the structure of the repo 2025-08-14 07:29:02 -07:00
3f670fa9f7 Update docs and example firmware 2025-08-13 07:42:02 -07:00
b5029bde88 add skeleton app to examples 2025-08-13 07:19:06 -07:00
4bcd618073 Add skeleton app to examples 2025-08-13 07:18:45 -07:00
6ada2aba30 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
2025-08-10 02:47:59 +00:00
c5965aa1f7 bug fix - need to recalculate pulses when mod duty and swing are changed. 2025-08-09 18:45:21 -07:00
7c02628403 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
2025-08-10 00:26:20 +00:00
1161da38c1 Add menu options for using cv input as Clock Run/Reset (#25)
Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/25
2025-08-10 00:25:06 +00:00
872af30fbc 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
2025-08-09 23:59:24 +00:00
fc17afc9a1 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
2025-08-09 23:57:10 +00:00
b6402380c0 fixed bug in cv mod of clock multiplication upper range. 2025-07-26 18:51:18 -07:00
19473db67e bump version in code 2025-07-24 18:38:34 -07:00
dd7217d04e Fix euclidean hit mod 2025-07-24 18:27:24 -07:00
d1c8ee16a4 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
2025-07-24 08:35:05 -07:00
65dde4d62e 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
2025-07-24 15:07:15 +00:00
c7a3277b5f Memory improvements in bootsplash and StateManager 2025-07-24 07:53:41 -07:00
fb44601707 Merge branch 'main' of https://git.pinkduck.xyz/awonak/libGravity 2025-07-23 18:08:10 -07:00
ec34bc3a7b Fix metadata loading issues with Initialization and refactor Factory Reset. (#19)
Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/19
2025-07-23 03:32:16 +00:00
c5bddef66d 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
2025-07-22 05:16:32 +00:00
b0accdc83a 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
2025-07-22 05:12:45 +00:00
1c0fb86bc1 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
2025-07-22 00:00:49 +00:00
01f32407f6 bump version 2025-07-20 17:53:03 -07:00
4f04137f67 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
2025-07-21 00:27:32 +00:00
1bf90e1674 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
2025-07-21 00:01:18 +00:00
5729eef037 Factory Reset (#13)
Fixes https://github.com/awonak/alt-gravity/issues/1

Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/13
2025-07-21 00:00:47 +00:00
30 changed files with 3208 additions and 1568 deletions

View File

@ -1,88 +0,0 @@
/**
* @file analog_input.h
* @author Adam Wonak (https://github.com/awonak)
* @brief Class for interacting with analog inputs.
* @version 0.1
* @date 2025-05-23
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
*/
#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.
*/
void Init(uint8_t pin) {
pinMode(pin, INPUT);
pin_ = pin;
}
/**
* Read the value of the analog input and set instance state.
*
*/
void Process() {
old_read_ = read_;
int raw = analogRead(pin_);
read_ = map(raw, 0, MAX_INPUT, low_, high_);
read_ = constrain(read_ - offset_, -512, 512);
if (inverted_) read_ = -read_;
}
// Set calibration values.
void AdjustCalibrationLow(int amount) { low_ += 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); }
private:
uint8_t pin_;
int16_t read_;
uint16_t old_read_;
// calibration values.
int offset_ = 0;
int low_ = CALIBRATED_LOW;
int high_ = CALIBRATED_HIGH;
bool inverted_ = false;
};
#endif

194
clock.h
View File

@ -1,194 +0,0 @@
/**
* @file clock.h
* @author Adam Wonak (https://github.com/awonak)
* @brief Wrapper Class for clock timing functions.
* @version 0.1
* @date 2025-05-04
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
*/
#ifndef CLOCK_H
#define CLOCK_H
#include <NeoHWSerial.h>
#include "peripherials.h"
#include "uClock.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
typedef void (*ExtCallback)(void);
static ExtCallback extUserCallback = nullptr;
static void serialEventNoop(uint8_t msg, uint8_t status) {}
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,
};
enum Pulse {
PULSE_NONE,
PULSE_PPQN_1,
PULSE_PPQN_4,
PULSE_PPQN_24,
PULSE_LAST,
};
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);
uClock.setOutputPPQN(uClock.PPQN_96);
uClock.setTempo(DEFAULT_TEMPO);
// MIDI events.
uClock.setOnClockStart(sendMIDIStart);
uClock.setOnClockStop(sendMIDIStop);
uClock.setOnSync24(sendMIDIClock);
uClock.start();
}
// Handle external clock tick and call user callback when receiving clock trigger (PPQN_4, PPQN_24, or MIDI).
void AttachExtHandler(void (*callback)()) {
extUserCallback = callback;
attachInterrupt(digitalPinToInterrupt(EXT_PIN), callback, RISING);
}
// Internal PPQN96 callback for all clock timer operations.
void AttachIntHandler(void (*callback)(uint32_t)) {
uClock.setOnOutputPPQN(callback);
}
// Set the source of the clock mode.
void SetSource(Source source) {
bool was_playing = !IsPaused();
uClock.stop();
// If we are changing the source from MIDI, disable the serial interrupt handler.
if (source_ == SOURCE_EXTERNAL_MIDI) {
NeoSerial.attachInterrupt(serialEventNoop);
}
source_ = source;
switch (source) {
case SOURCE_INTERNAL:
uClock.setClockMode(uClock.INTERNAL_CLOCK);
break;
case SOURCE_EXTERNAL_PPQN_24:
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
uClock.setInputPPQN(uClock.PPQN_24);
break;
case SOURCE_EXTERNAL_PPQN_4:
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
uClock.setInputPPQN(uClock.PPQN_4);
break;
case SOURCE_EXTERNAL_MIDI:
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
uClock.setInputPPQN(uClock.PPQN_24);
NeoSerial.attachInterrupt(onSerialEvent);
break;
}
if (was_playing) {
uClock.start();
}
}
// Return true if the current selected source is externl (PPQN_4, PPQN_24, or MIDI).
bool ExternalSource() {
return uClock.getClockMode() == uClock.EXTERNAL_CLOCK;
}
// Return true if the current selected source is the internal master clock.
bool InternalSource() {
return uClock.getClockMode() == uClock.INTERNAL_CLOCK;
}
// Returns the current BPM tempo.
int Tempo() {
return uClock.getTempo();
}
// Set the clock tempo to a int between 1 and 400.
void SetTempo(int tempo) {
return uClock.setTempo(tempo);
}
// Record an external clock tick received to process external/internal syncronization.
void Tick() {
uClock.clockMe();
}
// Start the internal clock.
void Start() {
uClock.start();
}
// Stop internal clock clock.
void Stop() {
uClock.stop();
}
// Reset all clock counters to 0.
void Reset() {
uClock.resetCounters();
}
// Returns true if the clock is not running.
bool IsPaused() {
return uClock.clock_state == uClock.PAUSED;
}
private:
Source source_ = SOURCE_INTERNAL;
static void onSerialEvent(uint8_t msg, uint8_t status) {
// Note: uClock start and stop will echo to MIDI.
switch (msg) {
case MIDI_CLOCK:
if (extUserCallback) {
extUserCallback();
}
break;
case MIDI_STOP:
uClock.stop();
sendMIDIStop();
break;
case MIDI_START:
case MIDI_CONTINUE:
uClock.start();
sendMIDIStart();
break;
}
}
static void sendMIDIStart() {
NeoSerial.write(MIDI_START);
}
static void sendMIDIStop() {
NeoSerial.write(MIDI_STOP);
}
static void sendMIDIClock(uint32_t tick) {
NeoSerial.write(MIDI_CLOCK);
}
};
#endif

View File

@ -1,94 +0,0 @@
/**
* @file digital_output.h
* @author Adam Wonak (https://github.com/awonak)
* @brief Class for interacting with trigger / gate outputs.
* @version 0.1
* @date 2025-04-17
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
*/
#ifndef DIGITAL_OUTPUT_H
#define DIGITAL_OUTPUT_H
#include <Arduino.h>
const byte DEFAULT_TRIGGER_DURATION_MS = 5;
class DigitalOutput {
public:
/**
* Initializes an CV Output paired object.
*
* @param cv_pin gpio pin for the cv output
*/
void Init(uint8_t cv_pin) {
pinMode(cv_pin, OUTPUT); // Gate/Trigger Output
cv_pin_ = cv_pin;
trigger_duration_ = DEFAULT_TRIGGER_DURATION_MS;
}
/**
* Set the trigger duration in miliseconds.
*
* @param duration_ms trigger duration in miliseconds
*/
void SetTriggerDuration(uint8_t duration_ms) {
trigger_duration_ = duration_ms;
}
/**
* Turn the CV and LED on or off according to the input state.
*
* @param state Arduino digital HIGH or LOW values.
*/
inline void Update(uint8_t state) {
if (state == HIGH) High(); // Rising
if (state == LOW) Low(); // Falling
}
// Sets the cv output HIGH to about 5v.
inline void High() { update(HIGH); }
// Sets the cv output LOW to 0v.
inline void Low() { update(LOW); }
/**
* Begin a Trigger period for this output.
*/
inline void Trigger() {
update(HIGH);
last_triggered_ = millis();
}
/**
* Return a bool representing the on/off state of the output.
*/
inline void Process() {
// If trigger is HIGH and the trigger duration time has elapsed, set the output low.
if (on_ && (millis() - last_triggered_) >= trigger_duration_) {
update(LOW);
}
}
/**
* Return a bool representing the on/off state of the output.
*
* @return true if current cv state is high, false if current cv state is low
*/
inline bool On() { return on_; }
private:
unsigned long last_triggered_;
uint8_t trigger_duration_;
uint8_t cv_pin_;
uint8_t led_pin_;
bool on_;
void update(uint8_t state) {
digitalWrite(cv_pin_, state);
on_ = state == HIGH;
}
};
#endif

View File

@ -17,7 +17,7 @@
*
*/
#include "gravity.h"
#include <libGravity.h>
// Firmware state variables.
struct Channel {

View File

@ -0,0 +1,417 @@
/**
* @file Gravity.ino
* @author Adam Wonak (https://github.com/awonak/)
* @brief Alt firmware version of Gravity by Sitka Instruments.
* @version v2.0.1beta1 - February 2026 awonak
* @date 2026-02-21
*
* @copyright MIT - (c) 2026 - Adam Wonak - adam.wonak@gmail.com
*
* This version of Gravity firmware is a full rewrite that leverages the
* libGravity hardware abstraction library. The goal of this project was to
* create an open source friendly version of the firmware that makes it easy
* for users/developers to modify and create their own original alt firmware
* implementations.
*
* The libGravity library represents wrappers around the
* hardware peripherials to make it easy to interact with and add behavior
* to them. The library tries not to make any assumptions about what the
* firmware can or should do.
*
* The Gravity firmware is a slightly different implementation of the original
* firmware. There are a few notable changes; the internal clock operates at
* 96 PPQN instead of the original 24 PPQN, which allows for more granular
* quantization of features like duty cycle (pulse width) or offset.
* Additionally, this firmware replaces the sequencer with a Euclidean Rhythm
* generator.
*
* ENCODER:
* Press: change between selecting a parameter and editing the parameter.
* Hold & Rotate: change current selected output channel.
*
* BTN1:
* Play/pause - start or stop the internal clock.
*
* BTN2:
* Shift - hold and rotate encoder to change current selected output
* channel.
*
* EXT:
* External clock input. When Gravity is set to INTERNAL or MIDI clock
* source, this input is used to reset clocks.
*
* CV1:
* External analog input used to provide modulation to any channel
* parameter.
*
* CV2:
* External analog input used to provide modulation to any channel
* parameter.
*
*/
#include <libGravity.h>
#include "app_state.h"
#include "channel.h"
#include "display.h"
#include "save_state.h"
AppState app;
StateManager stateManager;
//
// Arduino setup and loop.
//
void setup() {
// Start Gravity.
gravity.Init();
// Show bootsplash when initializing firmware.
Bootsplash();
delay(2000);
// Initialize the state manager. This will load settings from EEPROM
stateManager.initialize(app);
InitGravity(app);
// Clock handlers.
gravity.clock.AttachIntHandler(HandleIntClockTick);
gravity.clock.AttachExtHandler(HandleExtClockTick);
// Encoder rotate and press handlers.
gravity.encoder.AttachPressHandler(HandleEncoderPressed);
gravity.encoder.AttachRotateHandler(HandleRotate);
gravity.encoder.AttachPressRotateHandler(HandlePressedRotate);
// Button press handlers.
gravity.play_button.AttachPressHandler(HandlePlayPressed);
}
void loop() {
// Process change in state of inputs and outputs.
gravity.Process();
// Read CVs and call the update function for each channel.
int cv1 = gravity.cv1.Read();
int cv2 = gravity.cv2.Read();
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
auto &ch = app.channel[i];
// Only apply CV to the channel when the current channel has cv
// mod configured.
if (ch.isCvModActive()) {
ch.applyCvMod(cv1, cv2);
}
}
// Clock Run
if (app.cv_run == 1 || app.cv_run == 2) {
auto &cv = app.cv_run == 1 ? gravity.cv1 : gravity.cv2;
int val = cv.Read();
if (val > AnalogInput::GATE_THRESHOLD && gravity.clock.IsPaused()) {
gravity.clock.Start();
app.refresh_screen = true;
} else if (val < AnalogInput::GATE_THRESHOLD && !gravity.clock.IsPaused()) {
gravity.clock.Stop();
ResetOutputs();
app.refresh_screen = true;
}
}
// Clock Reset
if ((app.cv_reset == 1 &&
gravity.cv1.IsRisingEdge(AnalogInput::GATE_THRESHOLD)) ||
(app.cv_reset == 2 &&
gravity.cv2.IsRisingEdge(AnalogInput::GATE_THRESHOLD))) {
gravity.clock.Reset();
}
// Check for dirty state eligible to be saved.
stateManager.update(app);
if (app.refresh_screen) {
UpdateDisplay();
}
}
//
// Firmware handlers for clocks.
//
void HandleIntClockTick(uint32_t tick) {
bool refresh = false;
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
app.channel[i].processClockTick(tick, gravity.outputs[i]);
if (app.channel[i].isCvModActive()) {
refresh = true;
}
}
// Pulse Out gate
if (app.selected_pulse != Clock::PULSE_NONE) {
int clock_index;
switch (app.selected_pulse) {
case Clock::PULSE_PPQN_24:
clock_index = PULSE_PPQN_24_CLOCK_MOD_INDEX;
break;
case Clock::PULSE_PPQN_4:
clock_index = PULSE_PPQN_4_CLOCK_MOD_INDEX;
break;
case Clock::PULSE_PPQN_1:
clock_index = PULSE_PPQN_1_CLOCK_MOD_INDEX;
break;
}
const uint16_t pulse_high_ticks =
pgm_read_word_near(&CLOCK_MOD_PULSES[clock_index]);
const uint32_t pulse_low_ticks = tick + max((pulse_high_ticks / 2), 1L);
if (tick % pulse_high_ticks == 0) {
gravity.pulse.High();
} else if (pulse_low_ticks % pulse_high_ticks == 0) {
gravity.pulse.Low();
}
}
if (!app.editing_param) {
app.refresh_screen |= refresh;
}
}
void HandleExtClockTick() {
switch (app.selected_source) {
case Clock::SOURCE_INTERNAL:
case Clock::SOURCE_EXTERNAL_MIDI:
// Use EXT as Reset when not used for clock source.
ResetOutputs();
gravity.clock.Reset();
break;
default:
// Register EXT cv clock tick.
gravity.clock.Tick();
}
app.refresh_screen = true;
}
//
// UI handlers for encoder and buttons.
//
void HandlePlayPressed() {
// Check if SHIFT is pressed to mute all/current channel.
if (gravity.shift_button.On()) {
if (app.selected_channel == 0) {
// Mute all channels
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
app.channel[i].toggleMute();
}
} else {
// Mute selected channel
auto &ch = GetSelectedChannel();
ch.toggleMute();
}
return;
}
gravity.clock.IsPaused() ? gravity.clock.Start() : gravity.clock.Stop();
ResetOutputs();
app.refresh_screen = true;
}
void HandleEncoderPressed() {
// Check if leaving editing mode should apply a selection.
if (app.editing_param) {
if (app.selected_channel == 0) { // main page
// TODO: rewrite as switch
if (app.selected_param == PARAM_MAIN_ENCODER_DIR) {
app.encoder_reversed = app.selected_sub_param == 1;
gravity.encoder.SetReverseDirection(app.encoder_reversed);
}
if (app.selected_param == PARAM_MAIN_ROTATE_DISP) {
app.rotate_display = app.selected_sub_param == 1;
gravity.display.setFlipMode(app.rotate_display ? 1 : 0);
}
if (app.selected_param == PARAM_MAIN_SAVE_DATA) {
if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) {
app.selected_save_slot = app.selected_sub_param;
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;
// 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 if clock is not active.
if (gravity.clock.IsPaused()) {
InitGravity(app);
}
}
}
if (app.selected_param == PARAM_MAIN_RESET_STATE) {
if (app.selected_sub_param == 0) { // Reset
stateManager.reset(app);
InitGravity(app);
}
}
if (app.selected_param == PARAM_MAIN_FACTORY_RESET) {
if (app.selected_sub_param == 0) { // Erase
// Show bootsplash during slow erase operation.
Bootsplash();
stateManager.factoryReset(app);
InitGravity(app);
}
}
}
// Only mark dirty and reset selected_sub_param when leaving editing mode.
stateManager.markDirty();
app.selected_sub_param = 0;
}
app.editing_param = !app.editing_param;
app.refresh_screen = true;
}
void HandleRotate(int val) {
// Shift & Rotate check
if (gravity.shift_button.On()) {
HandlePressedRotate(val);
return;
}
if (!app.editing_param) {
// Navigation Mode
const int max_param =
(app.selected_channel == 0) ? PARAM_MAIN_LAST : PARAM_CH_LAST;
updateSelection(app.selected_param, val, max_param);
} else {
// Editing Mode
if (app.selected_channel == 0) {
editMainParameter(val);
} else {
editChannelParameter(val);
}
}
app.refresh_screen = true;
}
void HandlePressedRotate(int val) {
updateSelection(app.selected_channel, val, Gravity::OUTPUT_COUNT + 1);
app.selected_param = 0;
stateManager.markDirty();
app.refresh_screen = true;
}
void editMainParameter(int val) {
switch (static_cast<ParamsMainPage>(app.selected_param)) {
case PARAM_MAIN_TEMPO:
if (gravity.clock.ExternalSource()) {
break;
}
gravity.clock.SetTempo(gravity.clock.Tempo() + val);
app.tempo = gravity.clock.Tempo();
break;
case PARAM_MAIN_RUN:
updateSelection(app.selected_sub_param, val, 3);
app.cv_run = app.selected_sub_param;
break;
case PARAM_MAIN_RESET:
updateSelection(app.selected_sub_param, val, 3);
app.cv_reset = app.selected_sub_param;
break;
case PARAM_MAIN_SOURCE: {
byte source = static_cast<int>(app.selected_source);
updateSelection(source, val, Clock::SOURCE_LAST);
app.selected_source = static_cast<Clock::Source>(source);
gravity.clock.SetSource(app.selected_source);
break;
}
case PARAM_MAIN_PULSE: {
byte pulse = static_cast<int>(app.selected_pulse);
updateSelection(pulse, val, Clock::PULSE_LAST);
app.selected_pulse = static_cast<Clock::Pulse>(pulse);
if (app.selected_pulse == Clock::PULSE_NONE) {
gravity.pulse.Low();
}
break;
}
// These changes are applied upon encoder button press.
case PARAM_MAIN_ENCODER_DIR:
updateSelection(app.selected_sub_param, val, 2);
break;
case PARAM_MAIN_ROTATE_DISP:
updateSelection(app.selected_sub_param, val, 2);
break;
case PARAM_MAIN_SAVE_DATA:
case PARAM_MAIN_LOAD_DATA:
updateSelection(app.selected_sub_param, val,
StateManager::MAX_SAVE_SLOTS + 1);
break;
case PARAM_MAIN_RESET_STATE:
updateSelection(app.selected_sub_param, val, 2);
break;
case PARAM_MAIN_FACTORY_RESET:
updateSelection(app.selected_sub_param, val, 2);
break;
}
}
void editChannelParameter(int val) {
auto &ch = GetSelectedChannel();
switch (app.selected_param) {
case PARAM_CH_MOD:
ch.setClockMod(ch.getClockModIndex() + val);
break;
case PARAM_CH_EUC_STEPS:
ch.setSteps(ch.getSteps() + val);
break;
case PARAM_CH_EUC_HITS:
ch.setHits(ch.getHits() + val);
break;
case PARAM_CH_CV1_DEST: {
byte dest = static_cast<int>(ch.getCv1Dest());
updateSelection(dest, val, CV_DEST_LAST);
ch.setCv1Dest(static_cast<CvDestination>(dest));
break;
}
case PARAM_CH_CV2_DEST: {
byte dest = static_cast<int>(ch.getCv2Dest());
updateSelection(dest, val, CV_DEST_LAST);
ch.setCv2Dest(static_cast<CvDestination>(dest));
break;
}
}
}
// Changes the param by the value provided.
void updateSelection(byte &param, int change, int maxValue) {
// Do not apply acceleration if max value is less than 25.
if (maxValue < 25) {
change = change > 0 ? 1 : -1;
}
param = constrain(param + change, 0, maxValue - 1);
}
//
// App Helper functions.
//
void InitGravity(AppState &app) {
gravity.clock.SetTempo(app.tempo);
gravity.clock.SetSource(app.selected_source);
gravity.encoder.SetReverseDirection(app.encoder_reversed);
gravity.display.setFlipMode(app.rotate_display ? 1 : 0);
}
void ResetOutputs() {
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
gravity.outputs[i].Low();
}
}

View File

@ -0,0 +1,44 @@
/**
* @file app_state.h
* @author Adam Wonak (https://github.com/awonak/)
* @brief Alt firmware version of Gravity by Sitka Instruments.
* @version 2.0.1
* @date 2025-07-04
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
*/
#ifndef APP_STATE_H
#define APP_STATE_H
#include <libGravity.h>
#include "channel.h"
// Global state for settings and app behavior.
struct AppState {
int tempo = Clock::DEFAULT_TEMPO;
Channel channel[Gravity::OUTPUT_COUNT];
byte selected_param = 0;
byte selected_sub_param = 0; // Temporary value for editing params.
byte selected_channel = 0; // 0=tempo, 1-6=output channel
byte selected_swing = 0;
byte selected_save_slot = 0; // The currently active save slot.
Clock::Source selected_source = Clock::SOURCE_INTERNAL;
Clock::Pulse selected_pulse = Clock::PULSE_PPQN_24;
byte cv_run = 0;
byte cv_reset = 0;
bool editing_param = false;
bool encoder_reversed = false;
bool rotate_display = false;
bool refresh_screen = true;
};
extern AppState app;
static Channel &GetSelectedChannel() {
return app.channel[app.selected_channel - 1];
}
#endif // APP_STATE_H

View File

@ -0,0 +1,247 @@
/**
* @file channel.h
* @author Adam Wonak (https://github.com/awonak/)
* @brief Alt firmware version of Gravity by Sitka Instruments.
* @version 2.0.1
* @date 2025-07-04
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
*/
#ifndef CHANNEL_H
#define CHANNEL_H
#include <Arduino.h>
#include <libGravity.h>
#include "euclidean.h"
// Enums for CV Mod destination
enum CvDestination : uint8_t {
CV_DEST_NONE,
CV_DEST_MOD,
CV_DEST_EUC_STEPS,
CV_DEST_EUC_HITS,
CV_DEST_LAST,
};
static const byte MOD_CHOICE_SIZE = 25;
// Negative numbers are multipliers, positive are divisors.
static const int CLOCK_MOD[MOD_CHOICE_SIZE] PROGMEM = {
// Divisors
128, 64, 32, 24, 16, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2,
// Internal Clock Unity (quarter note)
1,
// Multipliers
-2, -3, -4, -6, -8, -12, -16, -24};
// This represents the number of clock pulses for a 96 PPQN clock source
// that match the above div/mult mods.
static const int CLOCK_MOD_PULSES[MOD_CHOICE_SIZE] PROGMEM = {
// Divisor Pulses (96 * X)
12288, 6144, 3072, 2304, 1536, 1152, 1056, 960, 864, 768, 672, 576, 480,
384, 288, 192,
// Internal Clock Pulses
96,
// Multiplier Pulses (96 / X)
48, 32, 24, 16, 12, 8, 6, 4};
static const byte DEFAULT_CLOCK_MOD_INDEX = 16; // x1 or 96 PPQN.
static const byte PULSE_PPQN_24_CLOCK_MOD_INDEX = MOD_CHOICE_SIZE - 1;
static const byte PULSE_PPQN_4_CLOCK_MOD_INDEX = MOD_CHOICE_SIZE - 6;
static const byte PULSE_PPQN_1_CLOCK_MOD_INDEX = MOD_CHOICE_SIZE - 9;
class Channel {
public:
Channel() { Init(); }
void Init() {
// Reset base values to their defaults
base_clock_mod_index = DEFAULT_CLOCK_MOD_INDEX;
base_euc_steps = 1;
base_euc_hits = 1;
cvmod_clock_mod_index = base_clock_mod_index;
cv1_dest = CV_DEST_NONE;
cv2_dest = CV_DEST_NONE;
pattern.Init(DEFAULT_PATTERN);
// Calcule the clock mod pulses on init.
_recalculatePulses();
}
// Setters (Set the BASE value)
void setClockMod(int index) {
base_clock_mod_index = constrain(index, 0, MOD_CHOICE_SIZE - 1);
if (!isCvModActive()) {
cvmod_clock_mod_index = base_clock_mod_index;
_recalculatePulses();
}
}
// Euclidean
void setSteps(int val) {
base_euc_steps = constrain(val, 1, MAX_PATTERN_LEN);
if (cv1_dest != CV_DEST_EUC_STEPS && cv2_dest != CV_DEST_EUC_STEPS) {
pattern.SetSteps(val);
}
}
void setHits(int val) {
base_euc_hits = constrain(val, 1, base_euc_steps);
if (cv1_dest != CV_DEST_EUC_HITS && cv2_dest != CV_DEST_EUC_HITS) {
pattern.SetHits(val);
}
}
void setCv1Dest(CvDestination dest) { cv1_dest = dest; }
void setCv2Dest(CvDestination dest) { cv2_dest = dest; }
CvDestination getCv1Dest() const { return cv1_dest; }
CvDestination getCv2Dest() const { return cv2_dest; }
// Getters (Get the BASE value for editing or cv modded value for display)
int getClockMod(bool withCvMod = false) const {
return pgm_read_word_near(&CLOCK_MOD[getClockModIndex(withCvMod)]);
}
int getClockModIndex(bool withCvMod = false) const {
return withCvMod ? cvmod_clock_mod_index : base_clock_mod_index;
}
bool isCvModActive() const {
return cv1_dest != CV_DEST_NONE || cv2_dest != CV_DEST_NONE;
}
byte getSteps(bool withCvMod = false) const {
return withCvMod ? pattern.GetSteps() : base_euc_steps;
}
byte getHits(bool withCvMod = false) const {
return withCvMod ? pattern.GetHits() : base_euc_hits;
}
void toggleMute() { mute = !mute; }
/**
* @brief Processes a clock tick and determines if the output should be high
* or low. Note: this method is called from an ISR and must be kept as simple
* as possible.
* @param tick The current clock tick count.
* @param output The output object to be modified.
*/
void processClockTick(uint32_t tick, DigitalOutput &output) {
// Mute check
if (mute) {
output.Low();
return;
}
const uint16_t mod_pulses =
pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]);
// Euclidian rhythm cycle check
if (!output.On()) {
// Step check
if (tick % mod_pulses == 0) {
bool hit = true;
// Euclidean rhythm hit check
switch (pattern.NextStep()) {
case Pattern::REST:
hit = false;
break;
case Pattern::HIT:
hit &= true;
break;
}
if (hit) {
output.High();
}
}
}
// Output low check. Half pulse width.
const uint32_t duty_cycle_end_tick = tick + _duty_pulses;
if (duty_cycle_end_tick % mod_pulses == 0) {
output.Low();
}
}
/**
* @brief Calculate and store cv modded values using bipolar mapping.
* Default to base value if not the current CV destination.
*
* @param cv1_val analog input reading for cv1
* @param cv2_val analog input reading for cv2
*
*/
void applyCvMod(int cv1_val, int cv2_val) {
// Note: This is optimized for cpu performance. This method is called
// from the main loop and stores the cv mod values. This reduces CPU
// cycles inside the internal clock interrupt, which is preferrable.
// However, if RAM usage grows too much, we have an opportunity to
// refactor this to store just the CV read values, and calculate the
// cv mod value per channel inside the getter methods by passing cv
// values. This would reduce RAM usage, but would introduce a
// significant CPU cost, which may have undesirable performance issues.
if (!isCvModActive()) {
cvmod_clock_mod_index = base_clock_mod_index;
return;
}
int dest_mod = _calculateMod(CV_DEST_MOD, cv1_val, cv2_val,
-(MOD_CHOICE_SIZE / 2), MOD_CHOICE_SIZE / 2);
cvmod_clock_mod_index = constrain(base_clock_mod_index + dest_mod, 0, 100);
int step_mod =
_calculateMod(CV_DEST_EUC_STEPS, cv1_val, cv2_val, 0, MAX_PATTERN_LEN);
pattern.SetSteps(base_euc_steps + step_mod);
int hit_mod = _calculateMod(CV_DEST_EUC_HITS, cv1_val, cv2_val, 0,
pattern.GetSteps());
pattern.SetHits(base_euc_hits + hit_mod);
// After all cvmod values are updated, recalculate clock pulse modifiers.
_recalculatePulses();
}
private:
int _calculateMod(CvDestination dest, int cv1_val, int cv2_val, int min_range,
int max_range) {
int mod1 =
(cv1_dest == dest) ? map(cv1_val, -512, 512, min_range, max_range) : 0;
int mod2 =
(cv2_dest == dest) ? map(cv2_val, -512, 512, min_range, max_range) : 0;
return mod1 + mod2;
}
void _recalculatePulses() {
const uint16_t mod_pulses =
pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]);
_duty_pulses = max((long)(mod_pulses / 2L), 1L);
}
// User-settable base values.
byte base_clock_mod_index;
byte base_euc_steps;
byte base_euc_hits;
// Base value with cv mod applied.
byte cvmod_clock_mod_index;
// CV mod configuration
CvDestination cv1_dest;
CvDestination cv2_dest;
// Euclidean pattern
Pattern pattern;
// Mute channel flag
bool mute;
// Pre-calculated pulse values for ISR performance
uint16_t _duty_pulses;
};
#endif // CHANNEL_H

View File

@ -0,0 +1,519 @@
/**
* @file display.h
* @author Adam Wonak (https://github.com/awonak/)
* @brief Alt firmware version of Gravity by Sitka Instruments.
* @version 2.0.1
* @date 2025-07-04
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
*/
#ifndef DISPLAY_H
#define DISPLAY_H
#include <Arduino.h>
#include "app_state.h"
#include "save_state.h"
//
// UI Display functions for drawing the UI to the OLED display.
//
/*
* Font: velvetscreen.bdf 9pt
* https://stncrn.github.io/u8g2-unifont-helper/
* "%/0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
*/
const uint8_t TEXT_FONT[437] U8G2_FONT_SECTION("velvetscreen") PROGMEM =
"\64\0\2\2\3\3\2\3\4\5\5\0\0\5\0\5\0\0\221\0\0\1\230 \4\200\134%\11\255tT"
"R\271RI(\6\252\334T\31)\7\252\134bJ\12+\7\233\345\322J\0,\5\221T\4-\5\213"
"f\6.\5\211T\2/"
"\6\244\354c\33\60\10\254\354T\64\223\2\61\7\353\354\222\254\6\62\11\254l"
"\66J*"
"\217\0\63\11\254l\66J\32\215\4\64\10\254l\242\34\272\0\65\11\254l\206\336h"
"$\0\66"
"\11\254\354T^\61)\0\67\10\254lF\216u\4\70\11\254\354TL*&"
"\5\71\11\254\354TL;"
")\0:\6\231UR\0A\10\254\354T\34S\6B\11\254lV\34)\216\4C\11\254\354T\324\61"
")\0D\10\254lV\64G\2E\10\254l\206\36z\4F\10\254l\206^\71\3G\11\254\354TN"
"\63)"
"\0H\10\254l\242\34S\6I\6\251T\206\0J\10\254\354k\231\24\0K\11\254l\242J\62"
"\225\1L\7\254lr{\4M\11\255t\362ZI\353\0N\11\255t\362TI\356\0O\10\254\354T"
"\64\223\2P\11\254lV\34)"
"g\0Q\10\254\354T\264b\12R\10\254lV\34\251\31S\11\254\354"
"FF\32\215\4T\7\253dVl\1U\10\254l\242\63)\0V\11\255t\262Ne\312\21W\12\255"
"t\262J*\251.\0X\11\254l\242L*\312\0Y\12\255tr\252\63\312(\2Z\7\253df*"
"\7p\10\255\364V\266\323\2q\7\255\364\216\257\5r\10\253d\242\32*"
"\2t\6\255t\376#w\11"
"\255\364V\245FN\13x\6\233dR\7\0\0\0\4\377\377\0";
/*
* Font: STK-L.bdf 36pt
* https://stncrn.github.io/u8g2-unifont-helper/
* "%/0123456789ABCDEFILNORSTUVXx"
*/
const uint8_t LARGE_FONT[766] U8G2_FONT_SECTION("stk-l") =
"\35\0\4\4\4\5\3\1\6\20\30\0\0\27\0\0\0\1\77\0\0\2\341%'\17;\226\261\245FL"
"\64B\214\30\22\223\220)"
"Bj\10Q\232\214\42R\206\310\210\21d\304\30\32a\254\304\270!\0/\14"
"\272\272\275\311H\321g\343\306\1\60\37|\373\35CJT\20:"
"fW\207\320\210\60\42\304\204\30D\247"
"\214\331\354\20\11%"
"\212\314\0\61\24z\275\245a\244\12\231\71\63b\214\220q\363\377(E\6\62\33|"
"\373\35ShT\20:fl\344\14\211\231\301\306T\71\202#g\371\340\201\1\63\34|"
"\373\35ShT"
"\20:fl\344@r\264\263\222\344,\215\35\42\241\6\225\31\0\64 "
"|\373-!\203\206\214!\62\204"
"\314\220A#\10\215\30\65b\324\210Q\306\354\354\1\213\225\363\1\65\32|"
"\373\15\25[\214\234/\10)"
"Y\61j\350\310Y\32;DB\15*\63\0\66\33}\33\236SiV\14;gt^\230Y\302\202\324"
"\71\273;EbM\252\63\0\67\23|\373\205\25\17R\316\207\344\350p\312\201#"
"\347\35\0\70 |\373"
"\35ShT\20:f\331!\22D\310 "
":\205\206\10\11B\307\354\354\20\11\65\250\314\0\71\32|\373"
"\35ShT\20:fg\207H,Q\223r\276\30DB\15*\63\0A\26}\33\246r\247\322P\62"
"j\310\250\21\343\354\335\203\357\354w\3B$}"
"\33\206Dj\226\214\42\61l\304\260\21\303F\14\33\61"
"\212\304\222MF\221\30v\316\236=\10\301b\11\0C\27}"
"\33\236Si\226\20Bft\376O\211\215"
" Db\215\42$\0D\33}\33\206Dj\226\214\32\62l\304\260\21\343\354\177vl\304("
"\22K\324"
"$\2E\22|\373\205\17R\316KD\30\215\234_>x`\0F\20|"
"\373\205\17R\316\227i\262\31"
"\71\377\22\0I\7s\333\204\77HL\15{\333\205\201\363\377\77|\360`\0N$}"
"\33\6\201\346\314"
"\35;\206\12U\242D&\306\230\30cd\210\221!fF\230\31a(+\314\256\63\67\0O\26}"
"\33"
"\236Si\226\214\32\61\316\376\277\33\61j\310\232Tg\0R\61\216;\6Ek\230\14#"
"\61n\304\270"
"\21\343F\214\33\61n\304\60\22\243\210\60Q\224j\310\260\61\243\306\20\232"
"\325\230QD\206\221\30\67b"
"\334\301\1S\42\216;\236c\211\226\220\42\61n\304\270\21c\307R\232,["
"\262\203\307\216\65h\16\25"
"\21&\253\320\0T\15}\33\206\17R\15\235\377\377\25\0U\21|"
"\373\205a\366\377\237\215\30\64D\15"
"*\63\0V\26\177\371\205\221\366\377\313\21\343\206\220\42C\25\11r'"
"\313\16\3X)~;\206\201\6"
"\217\221\30\66\204\20\31\42\244\206\14Cg\320$Q\222\6\315!"
"\33\62\212\10\31BD\206\215 v\320"
"\302\1x\24\312\272\205A\206\216\220@c\212\224\31$"
"S\14\262h\0\0\0\0\4\377\377\0";
#define play_icon_width 14
#define play_icon_height 14
static const unsigned char play_icon[28] PROGMEM = {
0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x7C, 0x00, 0xFC, 0x00,
0xFC, 0x03, 0xFC, 0x0F, 0xFC, 0x0F, 0xFC, 0x03, 0xFC, 0x00,
0x7C, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x00, 0x00};
static const unsigned char pause_icon[28] PROGMEM = {
0x00, 0x00, 0x00, 0x00, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E,
0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E,
0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x00, 0x00};
// Constants for screen layout and fonts
constexpr uint8_t SCREEN_CENTER_X = 32;
constexpr uint8_t MAIN_TEXT_Y = 26;
constexpr uint8_t SUB_TEXT_Y = 40;
constexpr uint8_t VISIBLE_MENU_ITEMS = 3;
constexpr uint8_t MENU_ITEM_HEIGHT = 14;
constexpr uint8_t MENU_BOX_PADDING = 4;
constexpr uint8_t MENU_BOX_WIDTH = 64;
constexpr uint8_t CHANNEL_BOXES_Y = 50;
constexpr uint8_t CHANNEL_BOX_WIDTH = 18;
constexpr uint8_t CHANNEL_BOX_HEIGHT = 14;
// Menu items for editing global parameters.
enum ParamsMainPage : uint8_t {
PARAM_MAIN_TEMPO,
PARAM_MAIN_RUN,
PARAM_MAIN_RESET,
PARAM_MAIN_SOURCE,
PARAM_MAIN_PULSE,
PARAM_MAIN_ENCODER_DIR,
PARAM_MAIN_ROTATE_DISP,
PARAM_MAIN_SAVE_DATA,
PARAM_MAIN_LOAD_DATA,
PARAM_MAIN_RESET_STATE,
PARAM_MAIN_FACTORY_RESET,
PARAM_MAIN_LAST,
};
// Menu items for editing channel parameters.
enum ParamsChannelPage : uint8_t {
PARAM_CH_MOD,
PARAM_CH_EUC_STEPS,
PARAM_CH_EUC_HITS,
PARAM_CH_CV1_DEST,
PARAM_CH_CV2_DEST,
PARAM_CH_LAST,
};
// Helper function to draw centered text
void drawCenteredText(const char *text, int y, const uint8_t *font) {
gravity.display.setFont(font);
int textWidth = gravity.display.getStrWidth(text);
gravity.display.drawStr(SCREEN_CENTER_X - (textWidth / 2), y, text);
}
// Helper function to draw right-aligned text
void drawRightAlignedText(const char *text, int y) {
int textWidth = gravity.display.getStrWidth(text);
int drawX = (SCREEN_WIDTH - textWidth) - MENU_BOX_PADDING;
gravity.display.drawStr(drawX, y, text);
}
void drawMainSelection() {
gravity.display.setDrawColor(1);
const int tickSize = 3;
const int mainWidth = SCREEN_WIDTH / 2;
const int mainHeight = 49;
gravity.display.drawLine(0, 0, tickSize, 0);
gravity.display.drawLine(0, 0, 0, tickSize);
gravity.display.drawLine(mainWidth, 0, mainWidth - tickSize, 0);
gravity.display.drawLine(mainWidth, 0, mainWidth, tickSize);
gravity.display.drawLine(mainWidth, mainHeight, mainWidth,
mainHeight - tickSize);
gravity.display.drawLine(mainWidth, mainHeight, mainWidth - tickSize,
mainHeight);
gravity.display.drawLine(0, mainHeight, tickSize, mainHeight);
gravity.display.drawLine(0, mainHeight, 0, mainHeight - tickSize);
gravity.display.setDrawColor(2);
}
void drawMenuItems(String menu_items[], int menu_size) {
// Draw menu items
gravity.display.setFont(TEXT_FONT);
// Draw selected menu item box
int selectedBoxY = 0;
if (menu_size >= VISIBLE_MENU_ITEMS && app.selected_param == menu_size - 1) {
selectedBoxY = MENU_ITEM_HEIGHT * min(2, app.selected_param);
} else if (app.selected_param > 0) {
selectedBoxY = MENU_ITEM_HEIGHT;
}
int boxX = MENU_BOX_WIDTH + 1;
int boxY = selectedBoxY + 2;
int boxWidth = MENU_BOX_WIDTH - 1;
int boxHeight = MENU_ITEM_HEIGHT + 1;
if (app.editing_param) {
gravity.display.drawBox(boxX, boxY, boxWidth, boxHeight);
drawMainSelection();
} else {
gravity.display.drawFrame(boxX, boxY, boxWidth, boxHeight);
}
// Draw the visible menu items
int start_index = 0;
if (menu_size >= VISIBLE_MENU_ITEMS && app.selected_param == menu_size - 1) {
start_index = menu_size - VISIBLE_MENU_ITEMS;
} else if (app.selected_param > 0) {
start_index = app.selected_param - 1;
}
for (int i = 0; i < min(menu_size, VISIBLE_MENU_ITEMS); ++i) {
int idx = start_index + i;
drawRightAlignedText(menu_items[idx].c_str(),
MENU_ITEM_HEIGHT * (i + 1) - 1);
}
}
// Visual indicators for main section of screen.
inline void solidTick() { gravity.display.drawBox(56, 4, 4, 4); }
inline void hollowTick() { gravity.display.drawBox(56, 4, 4, 4); }
// Human friendly display value for save slot.
String displaySaveSlot(int slot) {
if (slot >= 0 && slot < StateManager::MAX_SAVE_SLOTS / 2) {
return String("A") + String(slot + 1);
} else if (slot >= StateManager::MAX_SAVE_SLOTS / 2 &&
slot <= StateManager::MAX_SAVE_SLOTS) {
return String("B") + String(slot - (StateManager::MAX_SAVE_SLOTS / 2) + 1);
}
}
// Main display functions
void DisplayMainPage() {
gravity.display.setFontMode(1);
gravity.display.setDrawColor(2);
gravity.display.setFont(TEXT_FONT);
// Display selected editable value
String mainText;
String subText;
switch (app.selected_param) {
case PARAM_MAIN_TEMPO:
// Serial MIDI is too unstable to display bpm in real time.
if (app.selected_source == Clock::SOURCE_EXTERNAL_MIDI) {
mainText = F("EXT");
} else {
mainText = String(gravity.clock.Tempo());
}
subText = F("BPM");
break;
case PARAM_MAIN_RUN:
mainText = F("RUN");
switch (app.cv_run) {
case 0:
subText = F("NONE");
break;
case 1:
subText = F("CV1 GATE");
break;
case 2:
subText = F("CV2 GATE");
break;
}
break;
case PARAM_MAIN_RESET:
mainText = F("RST");
switch (app.cv_reset) {
case 0:
subText = F("NONE");
break;
case 1:
subText = F("CV1 TRIG");
break;
case 2:
subText = F("CV2 TRIG");
break;
}
break;
case PARAM_MAIN_SOURCE:
mainText = F("EXT");
switch (app.selected_source) {
case Clock::SOURCE_INTERNAL:
mainText = F("INT");
subText = F("CLOCK");
break;
case Clock::SOURCE_EXTERNAL_PPQN_24:
subText = F("24 PPQN");
break;
case Clock::SOURCE_EXTERNAL_PPQN_4:
subText = F("4 PPQN");
break;
case Clock::SOURCE_EXTERNAL_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;
}
break;
case PARAM_MAIN_PULSE:
mainText = F("OUT");
switch (app.selected_pulse) {
case Clock::PULSE_NONE:
subText = F("PULSE OFF");
break;
case Clock::PULSE_PPQN_24:
subText = F("24 PPQN PULSE");
break;
case Clock::PULSE_PPQN_4:
subText = F("4 PPQN PULSE");
break;
case Clock::PULSE_PPQN_1:
subText = F("1 PPQN PULSE");
break;
}
break;
case PARAM_MAIN_ENCODER_DIR:
mainText = F("DIR");
subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("REVERSED");
break;
case PARAM_MAIN_ROTATE_DISP:
mainText = F("DISP");
subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("ROTATED");
break;
case PARAM_MAIN_SAVE_DATA:
case PARAM_MAIN_LOAD_DATA:
if (app.selected_sub_param == StateManager::MAX_SAVE_SLOTS) {
mainText = F("x");
subText = F("BACK TO MAIN");
} else {
// Indicate currently active slot.
if (app.selected_sub_param == app.selected_save_slot) {
solidTick();
}
mainText = displaySaveSlot(app.selected_sub_param);
subText = (app.selected_param == PARAM_MAIN_SAVE_DATA)
? F("SAVE TO SLOT")
: F("LOAD FROM SLOT");
}
break;
case PARAM_MAIN_RESET_STATE:
if (app.selected_sub_param == 0) {
mainText = F("RST");
subText = F("RESET ALL");
} else {
mainText = F("x");
subText = F("BACK TO MAIN");
}
break;
case PARAM_MAIN_FACTORY_RESET:
if (app.selected_sub_param == 0) {
mainText = F("DEL");
subText = F("FACTORY RESET");
} else {
mainText = F("x");
subText = F("BACK TO MAIN");
}
break;
}
drawCenteredText(mainText.c_str(), MAIN_TEXT_Y, LARGE_FONT);
drawCenteredText(subText.c_str(), SUB_TEXT_Y, TEXT_FONT);
// Draw Main Page menu items
String menu_items[PARAM_MAIN_LAST] = {
F("TEMPO"), F("RUN"), F("RST"), F("SOURCE"),
F("PULSE OUT"), F("ENCODER DIR"), F("ROTATE DISP"), F("SAVE"),
F("LOAD"), F("RESET"), F("ERASE")};
drawMenuItems(menu_items, PARAM_MAIN_LAST);
}
void DisplayChannelPage() {
auto &ch = GetSelectedChannel();
gravity.display.setFontMode(1);
gravity.display.setDrawColor(2);
// Display selected editable value
String mainText;
String subText;
// When editing a param, just show the base value. When not editing show
// the value with cv mod.
bool withCvMod = !app.editing_param;
switch (app.selected_param) {
case PARAM_CH_MOD: {
int mod_value = ch.getClockMod(withCvMod);
if (mod_value > 1) {
mainText = F("/");
mainText += String(mod_value);
subText = F("DIVIDE");
} else {
mainText = F("x");
mainText += String(abs(mod_value));
subText = F("MULTIPLY");
}
break;
}
case PARAM_CH_EUC_STEPS:
mainText = String(ch.getSteps(withCvMod));
subText = "EUCLID STEPS";
break;
case PARAM_CH_EUC_HITS:
mainText = String(ch.getHits(withCvMod));
subText = "EUCLID HITS";
break;
case PARAM_CH_CV1_DEST:
case PARAM_CH_CV2_DEST: {
mainText = (app.selected_param == PARAM_CH_CV1_DEST) ? F("CV1") : F("CV2");
switch ((app.selected_param == PARAM_CH_CV1_DEST) ? ch.getCv1Dest()
: ch.getCv2Dest()) {
case CV_DEST_NONE:
subText = F("NONE");
break;
case CV_DEST_MOD:
subText = F("CLOCK MOD");
break;
case CV_DEST_EUC_STEPS:
subText = F("EUCLID STEPS");
break;
case CV_DEST_EUC_HITS:
subText = F("EUCLID HITS");
break;
}
break;
}
}
drawCenteredText(mainText.c_str(), MAIN_TEXT_Y, LARGE_FONT);
drawCenteredText(subText.c_str(), SUB_TEXT_Y, TEXT_FONT);
// Draw Channel Page menu items
String menu_items[PARAM_CH_LAST] = {F("MOD"), F("EUCLID STEPS"),
F("EUCLID HITS"), F("CV1 MOD"),
F("CV2 MOD")};
drawMenuItems(menu_items, PARAM_CH_LAST);
}
void DisplaySelectedChannel() {
int boxX = CHANNEL_BOX_WIDTH;
int boxY = CHANNEL_BOXES_Y;
int boxWidth = CHANNEL_BOX_WIDTH;
int boxHeight = CHANNEL_BOX_HEIGHT;
int textOffset = 7; // Half of font width
// Draw top and right side of frame.
gravity.display.drawHLine(1, boxY, SCREEN_WIDTH - 2);
gravity.display.drawVLine(SCREEN_WIDTH - 2, boxY, boxHeight);
for (int i = 0; i < Gravity::OUTPUT_COUNT + 1; i++) {
// Draw box frame or filled selected box.
gravity.display.setDrawColor(1);
(app.selected_channel == i)
? gravity.display.drawBox(i * boxWidth, boxY, boxWidth, boxHeight)
: gravity.display.drawVLine(i * boxWidth, boxY, boxHeight);
// Draw clock status icon or each channel number.
gravity.display.setDrawColor(2);
if (i == 0) {
gravity.display.setBitmapMode(1);
auto icon = gravity.clock.IsPaused() ? pause_icon : play_icon;
gravity.display.drawXBMP(2, boxY, play_icon_width, play_icon_height,
icon);
} else {
gravity.display.setFont(TEXT_FONT);
gravity.display.setCursor((i * boxWidth) + textOffset, SCREEN_HEIGHT - 3);
gravity.display.print(i);
}
}
}
void UpdateDisplay() {
app.refresh_screen = false;
gravity.display.firstPage();
do {
if (app.selected_channel == 0) {
DisplayMainPage();
} else {
DisplayChannelPage();
}
// Global channel select UI.
DisplaySelectedChannel();
} while (gravity.display.nextPage());
}
void Bootsplash() {
gravity.display.firstPage();
do {
int textWidth;
String loadingText = F("LOADING....");
gravity.display.setFont(TEXT_FONT);
textWidth = gravity.display.getStrWidth(StateManager::SKETCH_NAME);
gravity.display.drawStr(4 + (textWidth / 2), 22, 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

View File

@ -0,0 +1,237 @@
/**
* @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 <EEPROM.h>
#include "app_state.h"
// Define the constants for the current firmware.
const char StateManager::SKETCH_NAME[] = "ALT EUCLIDEAN";
const char StateManager::SEMANTIC_VERSION[] =
"V2.0.1BETA1"; // NOTE: This should match the version in the
// library.properties file.
// Number of available save slots.
const byte StateManager::MAX_SAVE_SLOTS = 10;
const byte StateManager::TRANSIENT_SLOT = 10;
// Define the minimum amount of time between EEPROM writes.
const unsigned long StateManager::SAVE_DELAY_MS = 2000;
// Calculate the starting address for EepromData, leaving space for metadata.
const int StateManager::METADATA_START_ADDR = 0;
const int StateManager::EEPROM_DATA_START_ADDR = sizeof(StateManager::Metadata);
StateManager::StateManager() : _isDirty(false), _lastChangeTime(0) {}
bool StateManager::initialize(AppState &app) {
noInterrupts();
bool success = false;
if (_isDataValid()) {
// Load global settings.
_loadMetadata(app);
// Load app data from the transient slot.
_loadState(app, TRANSIENT_SLOT);
success = true;
}
// EEPROM does not contain save data for this firmware & version.
else {
// Erase EEPROM and initialize state. Save default pattern to all save
// slots.
factoryReset(app);
}
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;
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) {
interrupts();
return;
}
_saveState(app, app.selected_save_slot);
_saveMetadata(app);
_isDirty = false;
interrupts();
}
// Save transient state if it has changed and enough time has passed since last
// save.
void StateManager::update(const AppState &app) {
if (_isDirty && (millis() - _lastChangeTime > SAVE_DELAY_MS)) {
noInterrupts();
_saveState(app, TRANSIENT_SLOT);
_saveMetadata(app);
_isDirty = false;
interrupts();
}
}
void StateManager::reset(AppState &app) {
noInterrupts();
AppState default_app;
app.tempo = default_app.tempo;
app.selected_param = default_app.selected_param;
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();
}
// Load global settings from Metadata
_loadMetadata(app);
_isDirty = false;
interrupts();
}
void StateManager::markDirty() {
_isDirty = true;
_lastChangeTime = millis();
}
// Erases all data in the EEPROM by writing 0 to every address.
void StateManager::factoryReset(AppState &app) {
noInterrupts();
for (unsigned int i = 0; i < EEPROM.length(); i++) {
EEPROM.write(i, 0);
}
// Initialize eeprom and save default patter to all save slots.
_saveMetadata(app);
reset(app);
for (int i = 0; i < MAX_SAVE_SLOTS; i++) {
app.selected_save_slot = i;
_saveState(app, i);
}
_saveState(app, TRANSIENT_SLOT);
interrupts();
}
bool StateManager::_isDataValid() {
Metadata metadata;
EEPROM.get(METADATA_START_ADDR, metadata);
bool name_match = (strcmp(metadata.sketch_name, SKETCH_NAME) == 0);
bool version_match = (strcmp(metadata.version, SEMANTIC_VERSION) == 0);
return name_match && version_match;
}
void StateManager::_saveState(const AppState &app, byte slot_index) {
// Check if slot_index is within max range + 1 for transient.
if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1)
return;
static EepromData save_data;
save_data.tempo = app.tempo;
save_data.selected_param = app.selected_param;
save_data.selected_channel = app.selected_channel;
save_data.selected_source = static_cast<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
// "update" only save state for the current channel since other channels
// will not have changed when saving user edits.
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
const auto &ch = app.channel[i];
auto &save_ch = save_data.channel_data[i];
save_ch.base_clock_mod_index = ch.getClockModIndex(false);
save_ch.base_euc_steps = ch.getSteps(false);
save_ch.base_euc_hits = ch.getHits(false);
save_ch.cv1_dest = static_cast<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);
}
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;
static EepromData load_data;
int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData));
EEPROM.get(address, load_data);
// Restore app state from loaded data.
app.tempo = load_data.tempo;
app.selected_param = load_data.selected_param;
app.selected_channel = load_data.selected_channel;
app.selected_source = static_cast<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];
const auto &saved_ch_state = load_data.channel_data[i];
ch.setClockMod(saved_ch_state.base_clock_mod_index);
ch.setSteps(saved_ch_state.base_euc_steps);
ch.setHits(saved_ch_state.base_euc_hits);
ch.setCv1Dest(static_cast<CvDestination>(saved_ch_state.cv1_dest));
ch.setCv2Dest(static_cast<CvDestination>(saved_ch_state.cv2_dest));
}
}
void StateManager::_saveMetadata(const AppState &app) {
Metadata current_meta;
strcpy(current_meta.sketch_name, SKETCH_NAME);
strcpy(current_meta.version, SEMANTIC_VERSION);
// Global user settings
current_meta.selected_save_slot = app.selected_save_slot;
current_meta.encoder_reversed = app.encoder_reversed;
current_meta.rotate_display = app.rotate_display;
EEPROM.put(METADATA_START_ADDR, current_meta);
}
void StateManager::_loadMetadata(AppState &app) {
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;
}

View File

@ -0,0 +1,96 @@
/**
* @file save_state.h
* @author Adam Wonak (https://github.com/awonak/)
* @brief Alt firmware version of Gravity by Sitka Instruments.
* @version 2.0.1
* @date 2025-07-04
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
*/
#ifndef SAVE_STATE_H
#define SAVE_STATE_H
#include <Arduino.h>
#include <libGravity.h>
// Forward-declare AppState to avoid circular dependencies.
struct AppState;
/**
* @brief Manages saving and loading of the application state to and from
* EEPROM. The number of user slots is defined by MAX_SAVE_SLOTS, and one
* additional slot is reseved for transient state to persist state between power
* cycles before state is explicitly saved to a user slot. Metadata is stored in
* the beginning of the memory space which stores firmware version information
* to validate that the data can be loaded into the current version of AppState.
*/
class StateManager {
public:
static const char SKETCH_NAME[];
static const char SEMANTIC_VERSION[];
static const byte MAX_SAVE_SLOTS;
static const byte TRANSIENT_SLOT;
StateManager();
// Populate the AppState instance with values from EEPROM if they exist.
bool initialize(AppState &app);
// Load data from specified slot.
bool loadData(AppState &app, byte slot_index);
// Save data to specified slot.
void saveData(const AppState &app);
// Reset AppState instance back to default values.
void reset(AppState &app);
// Call from main loop, check if state has changed and needs to be saved.
void update(const AppState &app);
// Indicate that state has changed and we should save.
void markDirty();
// Erase all data stored in the EEPROM.
void factoryReset(AppState &app);
// This struct holds the data that identifies the firmware version.
struct Metadata {
char sketch_name[16];
char version[16];
// Additional global/hardware settings
byte selected_save_slot;
bool encoder_reversed;
bool rotate_display;
};
struct ChannelState {
byte base_clock_mod_index;
byte base_euc_steps;
byte base_euc_hits;
byte cv1_dest; // Cast the CvDestination enum as a byte for storage
byte cv2_dest; // Cast the CvDestination enum as a byte for storage
};
// This struct holds all the parameters we want to save.
struct EepromData {
int tempo;
byte selected_param;
byte selected_channel;
byte selected_source;
byte selected_pulse;
byte cv_run;
byte cv_reset;
ChannelState channel_data[Gravity::OUTPUT_COUNT];
};
private:
bool _isDataValid();
void _saveMetadata(const AppState &app);
void _loadMetadata(AppState &app);
void _saveState(const AppState &app, byte slot_index);
void _loadState(AppState &app, byte slot_index);
static const unsigned long SAVE_DELAY_MS;
static const int METADATA_START_ADDR;
static const int EEPROM_DATA_START_ADDR;
bool _isDirty;
unsigned long _lastChangeTime;
};
#endif // SAVE_STATE_H

View File

@ -2,11 +2,10 @@
* @file Gravity.ino
* @author Adam Wonak (https://github.com/awonak/)
* @brief Alt firmware version of Gravity by Sitka Instruments.
* @version v2.0.1 - June 2025 awonak - Full rewrite
* @version v1.0 - August 2023 Oleksiy H - Initial release
* @date 2025-07-04
* @version v2.0.1beta1 - February 2026 awonak
* @date 2026-02-21
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
* @copyright MIT - (c) 2026 - Adam Wonak - adam.wonak@gmail.com
*
* This version of Gravity firmware is a full rewrite that leverages the
* libGravity hardware abstraction library. The goal of this project was to
@ -25,7 +24,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,20 +32,25 @@
* BTN1:
* Play/pause - start or stop the internal clock.
*
* BTN2:
* Shift - hold and rotate encoder to change current selected output channel.
* 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.
*
* 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.
*
* External analog input used to provide modulation to any channel
* parameter.
*
*/
#include <gravity.h>
#include <libGravity.h>
#include "app_state.h"
#include "channel.h"
@ -61,49 +65,75 @@ StateManager stateManager;
//
void setup() {
// Start Gravity.
gravity.Init();
// Start Gravity.
gravity.Init();
// Initialize the state manager. This will load settings from EEPROM
stateManager.initialize(app);
InitGravity(app);
// Show bootsplash when initializing firmware.
Bootsplash();
delay(2000);
// Clock handlers.
gravity.clock.AttachIntHandler(HandleIntClockTick);
gravity.clock.AttachExtHandler(HandleExtClockTick);
// Initialize the state manager. This will load settings from EEPROM
stateManager.initialize(app);
InitGravity(app);
// Encoder rotate and press handlers.
gravity.encoder.AttachPressHandler(HandleEncoderPressed);
gravity.encoder.AttachRotateHandler(HandleRotate);
gravity.encoder.AttachPressRotateHandler(HandlePressedRotate);
// Clock handlers.
gravity.clock.AttachIntHandler(HandleIntClockTick);
gravity.clock.AttachExtHandler(HandleExtClockTick);
// Button press handlers.
gravity.play_button.AttachPressHandler(HandlePlayPressed);
// 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();
// Process change in state of inputs and outputs.
gravity.Process();
// Read CVs and call the update function for each channel.
int cv1 = gravity.cv1.Read();
int cv2 = gravity.cv2.Read();
// Read CVs and call the update function for each channel.
int cv1 = gravity.cv1.Read();
int cv2 = gravity.cv2.Read();
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
auto& ch = app.channel[i];
// Only apply CV to the channel when the current channel has cv
// mod configured.
if (ch.isCvModActive()) {
ch.applyCvMod(cv1, cv2);
}
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
auto &ch = app.channel[i];
// Only apply CV to the channel when the current channel has cv
// mod configured.
if (ch.isCvModActive()) {
ch.applyCvMod(cv1, cv2);
}
}
// Check for dirty state eligible to be saved.
stateManager.update(app);
if (app.refresh_screen) {
UpdateDisplay();
// Clock Run
if (app.cv_run == 1 || app.cv_run == 2) {
auto &cv = app.cv_run == 1 ? gravity.cv1 : gravity.cv2;
int val = cv.Read();
if (val > AnalogInput::GATE_THRESHOLD && gravity.clock.IsPaused()) {
gravity.clock.Start();
app.refresh_screen = true;
} else if (val < AnalogInput::GATE_THRESHOLD && !gravity.clock.IsPaused()) {
gravity.clock.Stop();
ResetOutputs();
app.refresh_screen = true;
}
}
// Clock Reset
if ((app.cv_reset == 1 &&
gravity.cv1.IsRisingEdge(AnalogInput::GATE_THRESHOLD)) ||
(app.cv_reset == 2 &&
gravity.cv2.IsRisingEdge(AnalogInput::GATE_THRESHOLD))) {
gravity.clock.Reset();
}
// Check for dirty state eligible to be saved.
stateManager.update(app);
if (app.refresh_screen) {
UpdateDisplay();
}
}
//
@ -111,56 +141,59 @@ void loop() {
//
void HandleIntClockTick(uint32_t tick) {
bool refresh = false;
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
app.channel[i].processClockTick(tick, gravity.outputs[i]);
bool refresh = false;
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
app.channel[i].processClockTick(tick, gravity.outputs[i]);
if (app.channel[i].isCvModActive()) {
refresh = true;
}
if (app.channel[i].isCvModActive()) {
refresh = true;
}
}
// Pulse Out gate
if (app.selected_pulse != Clock::PULSE_NONE) {
int clock_index;
switch (app.selected_pulse) {
case Clock::PULSE_PPQN_24:
clock_index = PULSE_PPQN_24_CLOCK_MOD_INDEX;
break;
case Clock::PULSE_PPQN_4:
clock_index = PULSE_PPQN_4_CLOCK_MOD_INDEX;
break;
case Clock::PULSE_PPQN_1:
clock_index = PULSE_PPQN_1_CLOCK_MOD_INDEX;
break;
}
// Pulse Out gate
if (app.selected_pulse != Clock::PULSE_NONE) {
int clock_index;
switch (app.selected_pulse) {
case Clock::PULSE_PPQN_24:
clock_index = PULSE_PPQN_24_CLOCK_MOD_INDEX;
break;
case Clock::PULSE_PPQN_4:
clock_index = PULSE_PPQN_4_CLOCK_MOD_INDEX;
break;
case Clock::PULSE_PPQN_1:
clock_index = PULSE_PPQN_1_CLOCK_MOD_INDEX;
break;
}
const uint16_t pulse_high_ticks =
pgm_read_word_near(&CLOCK_MOD_PULSES[clock_index]);
const uint32_t pulse_low_ticks = tick + max((pulse_high_ticks / 2), 1L);
const 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();
}
if (pulse_low_ticks % pulse_high_ticks == 0) {
gravity.pulse.Low();
}
if (tick % pulse_high_ticks == 0) {
gravity.pulse.High();
} else if (pulse_low_ticks % pulse_high_ticks == 0) {
gravity.pulse.Low();
}
}
if (!app.editing_param) {
app.refresh_screen |= refresh;
}
if (!app.editing_param) {
app.refresh_screen |= refresh;
}
}
void HandleExtClockTick() {
if (gravity.clock.InternalSource()) {
// Use EXT as Reset when internally clocked.
ResetOutputs();
gravity.clock.Reset();
} else {
// Register clock tick.
gravity.clock.Tick();
}
app.refresh_screen = true;
switch (app.selected_source) {
case Clock::SOURCE_INTERNAL:
case Clock::SOURCE_EXTERNAL_MIDI:
// Use EXT as Reset when not used for clock source.
ResetOutputs();
gravity.clock.Reset();
break;
default:
// Register EXT cv clock tick.
gravity.clock.Tick();
}
app.refresh_screen = true;
}
//
@ -168,178 +201,223 @@ void HandleExtClockTick() {
//
void HandlePlayPressed() {
gravity.clock.IsPaused()
? gravity.clock.Start()
: gravity.clock.Stop();
ResetOutputs();
app.refresh_screen = true;
// Check if SHIFT is pressed to mute all/current channel.
if (gravity.shift_button.On()) {
if (app.selected_channel == 0) {
// Mute all channels
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
app.channel[i].toggleMute();
}
} else {
// Mute selected channel
auto &ch = GetSelectedChannel();
ch.toggleMute();
}
return;
}
gravity.clock.IsPaused() ? gravity.clock.Start() : gravity.clock.Stop();
ResetOutputs();
app.refresh_screen = true;
}
void HandleEncoderPressed() {
// Check if leaving editing mode should apply a selection.
if (app.editing_param) {
if (app.selected_channel == 0) { // main page
// TODO: rewrite as switch
if (app.selected_param == PARAM_MAIN_ENCODER_DIR) {
bool reversed = app.selected_sub_param == 1;
gravity.encoder.SetReverseDirection(reversed);
}
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);
InitGravity(app);
}
}
// Check if leaving editing mode should apply a selection.
if (app.editing_param) {
if (app.selected_channel == 0) { // main page
// TODO: rewrite as switch
if (app.selected_param == PARAM_MAIN_ENCODER_DIR) {
app.encoder_reversed = app.selected_sub_param == 1;
gravity.encoder.SetReverseDirection(app.encoder_reversed);
}
if (app.selected_param == PARAM_MAIN_ROTATE_DISP) {
app.rotate_display = app.selected_sub_param == 1;
gravity.display.setFlipMode(app.rotate_display ? 1 : 0);
}
if (app.selected_param == PARAM_MAIN_SAVE_DATA) {
if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) {
app.selected_save_slot = app.selected_sub_param;
stateManager.saveData(app);
}
// Only mark dirty and reset selected_sub_param when leaving editing mode.
stateManager.markDirty();
app.selected_sub_param = 0;
}
if (app.selected_param == PARAM_MAIN_LOAD_DATA) {
if (app.selected_sub_param < StateManager::MAX_SAVE_SLOTS) {
app.selected_save_slot = app.selected_sub_param;
// 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 if clock is not active.
if (gravity.clock.IsPaused()) {
InitGravity(app);
}
}
}
if (app.selected_param == PARAM_MAIN_RESET_STATE) {
if (app.selected_sub_param == 0) { // Reset
stateManager.reset(app);
InitGravity(app);
}
}
if (app.selected_param == PARAM_MAIN_FACTORY_RESET) {
if (app.selected_sub_param == 0) { // Erase
// Show bootsplash during slow erase operation.
Bootsplash();
stateManager.factoryReset(app);
InitGravity(app);
}
}
}
// Only mark dirty and reset selected_sub_param when leaving editing mode.
stateManager.markDirty();
app.selected_sub_param = 0;
}
app.editing_param = !app.editing_param;
app.refresh_screen = true;
app.editing_param = !app.editing_param;
app.refresh_screen = true;
}
void HandleRotate(int val) {
// Shift & Rotate check
if (gravity.shift_button.On()) {
HandlePressedRotate(val);
return;
}
// Shift & Rotate check
if (gravity.shift_button.On()) {
HandlePressedRotate(val);
return;
}
if (!app.editing_param) {
// Navigation Mode
const int max_param = (app.selected_channel == 0) ? PARAM_MAIN_LAST : PARAM_CH_LAST;
updateSelection(app.selected_param, val, max_param);
if (!app.editing_param) {
// Navigation Mode
const int max_param =
(app.selected_channel == 0) ? PARAM_MAIN_LAST : PARAM_CH_LAST;
updateSelection(app.selected_param, val, max_param);
} else {
// Editing Mode
if (app.selected_channel == 0) {
editMainParameter(val);
} else {
// Editing Mode
if (app.selected_channel == 0) {
editMainParameter(val);
} else {
editChannelParameter(val);
}
editChannelParameter(val);
}
app.refresh_screen = true;
}
app.refresh_screen = true;
}
void HandlePressedRotate(int val) {
updateSelection(app.selected_channel, val, Gravity::OUTPUT_COUNT + 1);
app.selected_param = 0;
stateManager.markDirty();
app.refresh_screen = true;
updateSelection(app.selected_channel, val, Gravity::OUTPUT_COUNT + 1);
app.selected_param = 0;
stateManager.markDirty();
app.refresh_screen = true;
}
void editMainParameter(int val) {
switch (static_cast<ParamsMainPage>(app.selected_param)) {
case PARAM_MAIN_TEMPO:
if (gravity.clock.ExternalSource()) {
break;
}
gravity.clock.SetTempo(gravity.clock.Tempo() + val);
app.tempo = gravity.clock.Tempo();
break;
case PARAM_MAIN_SOURCE: {
byte source = static_cast<int>(app.selected_source);
updateSelection(source, val, Clock::SOURCE_LAST);
app.selected_source = static_cast<Clock::Source>(source);
gravity.clock.SetSource(app.selected_source);
break;
}
case PARAM_MAIN_PULSE: {
byte pulse = static_cast<int>(app.selected_pulse);
updateSelection(pulse, val, Clock::PULSE_LAST);
app.selected_pulse = static_cast<Clock::Pulse>(pulse);
if (app.selected_pulse == Clock::PULSE_NONE) {
gravity.pulse.Low();
}
break;
}
case PARAM_MAIN_ENCODER_DIR:
updateSelection(app.selected_sub_param, val, 2);
break;
case PARAM_MAIN_SAVE_DATA:
case PARAM_MAIN_LOAD_DATA:
updateSelection(app.selected_sub_param, val, MAX_SAVE_SLOTS + 1);
break;
case PARAM_MAIN_RESET_STATE:
updateSelection(app.selected_sub_param, val, 2);
break;
switch (static_cast<ParamsMainPage>(app.selected_param)) {
case PARAM_MAIN_TEMPO:
if (gravity.clock.ExternalSource()) {
break;
}
gravity.clock.SetTempo(gravity.clock.Tempo() + val);
app.tempo = gravity.clock.Tempo();
break;
case PARAM_MAIN_RUN:
updateSelection(app.selected_sub_param, val, 3);
app.cv_run = app.selected_sub_param;
break;
case PARAM_MAIN_RESET:
updateSelection(app.selected_sub_param, val, 3);
app.cv_reset = app.selected_sub_param;
break;
case PARAM_MAIN_SOURCE: {
byte source = static_cast<int>(app.selected_source);
updateSelection(source, val, Clock::SOURCE_LAST);
app.selected_source = static_cast<Clock::Source>(source);
gravity.clock.SetSource(app.selected_source);
break;
}
case PARAM_MAIN_PULSE: {
byte pulse = static_cast<int>(app.selected_pulse);
updateSelection(pulse, val, Clock::PULSE_LAST);
app.selected_pulse = static_cast<Clock::Pulse>(pulse);
if (app.selected_pulse == Clock::PULSE_NONE) {
gravity.pulse.Low();
}
break;
}
// These changes are applied upon encoder button press.
case PARAM_MAIN_ENCODER_DIR:
updateSelection(app.selected_sub_param, val, 2);
break;
case PARAM_MAIN_ROTATE_DISP:
updateSelection(app.selected_sub_param, val, 2);
break;
case PARAM_MAIN_SAVE_DATA:
case PARAM_MAIN_LOAD_DATA:
updateSelection(app.selected_sub_param, val,
StateManager::MAX_SAVE_SLOTS + 1);
break;
case PARAM_MAIN_RESET_STATE:
updateSelection(app.selected_sub_param, val, 2);
break;
case PARAM_MAIN_FACTORY_RESET:
updateSelection(app.selected_sub_param, val, 2);
break;
}
}
void editChannelParameter(int val) {
auto& ch = GetSelectedChannel();
switch (app.selected_param) {
case PARAM_CH_MOD:
ch.setClockMod(ch.getClockModIndex() + val);
break;
case PARAM_CH_PROB:
ch.setProbability(ch.getProbability() + val);
break;
case PARAM_CH_DUTY:
ch.setDutyCycle(ch.getDutyCycle() + val);
break;
case PARAM_CH_OFFSET:
ch.setOffset(ch.getOffset() + val);
break;
case PARAM_CH_SWING:
ch.setSwing(ch.getSwing() + val);
break;
case PARAM_CH_EUC_STEPS:
ch.setSteps(ch.getSteps() + val);
break;
case PARAM_CH_EUC_HITS:
ch.setHits(ch.getHits() + val);
break;
case PARAM_CH_CV1_DEST: {
byte dest = static_cast<int>(ch.getCv1Dest());
updateSelection(dest, val, CV_DEST_LAST);
ch.setCv1Dest(static_cast<CvDestination>(dest));
break;
}
case PARAM_CH_CV2_DEST: {
byte dest = static_cast<int>(ch.getCv2Dest());
updateSelection(dest, val, CV_DEST_LAST);
ch.setCv2Dest(static_cast<CvDestination>(dest));
break;
}
}
auto &ch = GetSelectedChannel();
switch (app.selected_param) {
case PARAM_CH_MOD:
ch.setClockMod(ch.getClockModIndex() + val);
break;
case PARAM_CH_PROB:
ch.setProbability(ch.getProbability() + val);
break;
case PARAM_CH_DUTY:
ch.setDutyCycle(ch.getDutyCycle() + val);
break;
case PARAM_CH_OFFSET:
ch.setOffset(ch.getOffset() + val);
break;
case PARAM_CH_SWING:
ch.setSwing(ch.getSwing() + val);
break;
case PARAM_CH_CV1_DEST: {
byte dest = static_cast<int>(ch.getCv1Dest());
updateSelection(dest, val, CV_DEST_LAST);
ch.setCv1Dest(static_cast<CvDestination>(dest));
break;
}
case PARAM_CH_CV2_DEST: {
byte dest = static_cast<int>(ch.getCv2Dest());
updateSelection(dest, val, CV_DEST_LAST);
ch.setCv2Dest(static_cast<CvDestination>(dest));
break;
}
}
}
// Changes the param by the value provided.
void updateSelection(byte& param, int change, int maxValue) {
// Do not apply acceleration if max value is less than 25.
if (maxValue < 25) {
change = change > 0 ? 1 : -1;
}
param = constrain(param + change, 0, maxValue - 1);
void updateSelection(byte &param, int change, int maxValue) {
// Do not apply acceleration if max value is less than 25.
if (maxValue < 25) {
change = change > 0 ? 1 : -1;
}
param = constrain(param + change, 0, maxValue - 1);
}
//
// App Helper functions.
//
void InitGravity(AppState& app) {
gravity.clock.SetTempo(app.tempo);
gravity.clock.SetSource(app.selected_source);
gravity.encoder.SetReverseDirection(app.encoder_reversed);
void InitGravity(AppState &app) {
gravity.clock.SetTempo(app.tempo);
gravity.clock.SetSource(app.selected_source);
gravity.encoder.SetReverseDirection(app.encoder_reversed);
gravity.display.setFlipMode(app.rotate_display ? 1 : 0);
}
void ResetOutputs() {
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
gravity.outputs[i].Low();
}
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
gravity.outputs[i].Low();
}
}

View File

@ -12,54 +12,33 @@
#ifndef APP_STATE_H
#define APP_STATE_H
#include <gravity.h>
#include <libGravity.h>
#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;
byte selected_param = 0;
byte selected_sub_param = 0; // Temporary value for editing params.
byte selected_channel = 0; // 0=tempo, 1-6=output channel
byte selected_swing = 0;
byte selected_save_slot = 0; // The currently active save slot.
Clock::Source selected_source = Clock::SOURCE_INTERNAL;
Clock::Pulse selected_pulse = Clock::PULSE_PPQN_24;
Channel channel[Gravity::OUTPUT_COUNT];
int tempo = Clock::DEFAULT_TEMPO;
Channel channel[Gravity::OUTPUT_COUNT];
byte selected_param = 0;
byte selected_sub_param = 0; // Temporary value for editing params.
byte selected_channel = 0; // 0=tempo, 1-6=output channel
byte selected_swing = 0;
byte selected_save_slot = 0; // The currently active save slot.
byte cv_run = 0;
byte cv_reset = 0;
Clock::Source selected_source = Clock::SOURCE_INTERNAL;
Clock::Pulse selected_pulse = Clock::PULSE_PPQN_24;
bool editing_param = false;
bool encoder_reversed = false;
bool rotate_display = false;
bool refresh_screen = true;
};
extern AppState app;
static Channel& GetSelectedChannel() {
return app.channel[app.selected_channel - 1];
static Channel &GetSelectedChannel() {
return app.channel[app.selected_channel - 1];
}
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
#endif // APP_STATE_H

View File

@ -13,295 +13,284 @@
#define CHANNEL_H
#include <Arduino.h>
#include <gravity.h>
#include "euclidean.h"
#include <libGravity.h>
// Enums for CV Mod destination
enum CvDestination : uint8_t {
CV_DEST_NONE,
CV_DEST_MOD,
CV_DEST_PROB,
CV_DEST_DUTY,
CV_DEST_OFFSET,
CV_DEST_SWING,
CV_DEST_EUC_STEPS,
CV_DEST_EUC_HITS,
CV_DEST_LAST,
CV_DEST_NONE,
CV_DEST_MOD,
CV_DEST_PROB,
CV_DEST_DUTY,
CV_DEST_OFFSET,
CV_DEST_SWING,
CV_DEST_LAST,
};
static const byte MOD_CHOICE_SIZE = 25;
// 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:
Channel() {
Init();
public:
Channel() { Init(); }
void Init() {
// Reset base values to their defaults
base_clock_mod_index = DEFAULT_CLOCK_MOD_INDEX;
base_probability = 100;
base_duty_cycle = 50;
base_offset = 0;
base_swing = 50;
cvmod_clock_mod_index = base_clock_mod_index;
cvmod_probability = base_probability;
cvmod_duty_cycle = base_duty_cycle;
cvmod_offset = base_offset;
cvmod_swing = base_swing;
cv1_dest = CV_DEST_NONE;
cv2_dest = CV_DEST_NONE;
// Calcule the clock mod pulses on init.
_recalculatePulses();
}
// Setters (Set the BASE value)
void setClockMod(int index) {
base_clock_mod_index = constrain(index, 0, MOD_CHOICE_SIZE - 1);
if (!isCvModActive()) {
cvmod_clock_mod_index = base_clock_mod_index;
_recalculatePulses();
}
}
void setProbability(int prob) {
base_probability = constrain(prob, 0, 100);
if (!isCvModActive()) {
cvmod_probability = base_probability;
_recalculatePulses();
}
}
void setDutyCycle(int duty) {
base_duty_cycle = constrain(duty, 1, 99);
if (!isCvModActive()) {
cvmod_duty_cycle = base_duty_cycle;
_recalculatePulses();
}
}
void setOffset(int off) {
base_offset = constrain(off, 0, 99);
if (!isCvModActive()) {
cvmod_offset = base_offset;
_recalculatePulses();
}
}
void setSwing(int val) {
base_swing = constrain(val, 50, 95);
if (!isCvModActive()) {
cvmod_swing = base_swing;
_recalculatePulses();
}
}
void setCv1Dest(CvDestination dest) { cv1_dest = dest; }
void setCv2Dest(CvDestination dest) { cv2_dest = dest; }
CvDestination getCv1Dest() const { return cv1_dest; }
CvDestination getCv2Dest() const { return cv2_dest; }
// Getters (Get the BASE value for editing or cv modded value for display)
int getProbability(bool withCvMod = false) const {
return withCvMod ? cvmod_probability : base_probability;
}
int getDutyCycle(bool withCvMod = false) const {
return withCvMod ? cvmod_duty_cycle : base_duty_cycle;
}
int getOffset(bool withCvMod = false) const {
return withCvMod ? cvmod_offset : base_offset;
}
int getSwing(bool withCvMod = false) const {
return withCvMod ? cvmod_swing : base_swing;
}
int getClockMod(bool withCvMod = false) const {
return pgm_read_word_near(&CLOCK_MOD[getClockModIndex(withCvMod)]);
}
int getClockModIndex(bool withCvMod = false) const {
return withCvMod ? cvmod_clock_mod_index : base_clock_mod_index;
}
bool isCvModActive() const {
return cv1_dest != CV_DEST_NONE || cv2_dest != CV_DEST_NONE;
}
void toggleMute() { mute = !mute; }
/**
* @brief Processes a clock tick and determines if the output should be high
* or low. Note: this method is called from an ISR and must be kept as simple
* as possible.
* @param tick The current clock tick count.
* @param output The output object to be modified.
*/
void processClockTick(uint32_t tick, DigitalOutput &output) {
// Mute check
if (mute) {
output.Low();
return;
}
void Init() {
// Reset base values to their defaults
base_clock_mod_index = DEFAULT_CLOCK_MOD_INDEX;
base_probability = 100;
base_duty_cycle = 50;
base_offset = 0;
base_swing = 50;
base_euc_steps = 1;
base_euc_hits = 1;
const uint16_t mod_pulses =
pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]);
cvmod_clock_mod_index = base_clock_mod_index;
cvmod_probability = base_probability;
cvmod_duty_cycle = base_duty_cycle;
cvmod_offset = base_offset;
cvmod_swing = base_swing;
cv1_dest = CV_DEST_NONE;
cv2_dest = CV_DEST_NONE;
pattern.Init(DEFAULT_PATTERN);
// Calcule the clock mod pulses on init.
_recalculatePulses();
// Conditionally apply swing on down beats.
uint16_t swing_pulses = 0;
if (_swing_pulse_amount > 0 && (tick / mod_pulses) % 2 == 1) {
swing_pulses = _swing_pulse_amount;
}
// Setters (Set the BASE value)
void setClockMod(int index) {
base_clock_mod_index = constrain(index, 0, MOD_CHOICE_SIZE - 1);
if (!isCvModActive()) {
cvmod_clock_mod_index = base_clock_mod_index;
_recalculatePulses();
// Duty cycle high check logic
const uint32_t current_tick_offset = tick + _offset_pulses + swing_pulses;
if (!output.On()) {
// Step check
if (current_tick_offset % mod_pulses == 0) {
bool hit = cvmod_probability >= random(0, 100);
if (hit) {
output.High();
}
}
}
void setProbability(int prob) {
base_probability = constrain(prob, 0, 100);
if (!isCvModActive()) {
cvmod_probability = base_probability;
_recalculatePulses();
}
// Duty cycle low check
const uint32_t duty_cycle_end_tick =
tick + _duty_pulses + _offset_pulses + swing_pulses;
if (duty_cycle_end_tick % mod_pulses == 0) {
output.Low();
}
}
/**
* @brief Calculate and store cv modded values using bipolar mapping.
* Default to base value if not the current CV destination.
*
* @param cv1_val analog input reading for cv1
* @param cv2_val analog input reading for cv2
*
*/
void applyCvMod(int cv1_val, int cv2_val) {
// Note: This is optimized for cpu performance. This method is called
// from the main loop and stores the cv mod values. This reduces CPU
// cycles inside the internal clock interrupt, which is preferrable.
// However, if RAM usage grows too much, we have an opportunity to
// refactor this to store just the CV read values, and calculate the
// cv mod value per channel inside the getter methods by passing cv
// values. This would reduce RAM usage, but would introduce a
// significant CPU cost, which may have undesirable performance issues.
if (!isCvModActive()) {
cvmod_clock_mod_index = base_clock_mod_index;
cvmod_probability = base_clock_mod_index;
cvmod_duty_cycle = base_clock_mod_index;
cvmod_offset = base_clock_mod_index;
cvmod_swing = base_clock_mod_index;
return;
}
void setDutyCycle(int duty) {
base_duty_cycle = constrain(duty, 1, 99);
if (!isCvModActive()) {
cvmod_duty_cycle = base_duty_cycle;
_recalculatePulses();
}
int dest_mod = _calculateMod(CV_DEST_MOD, cv1_val, cv2_val,
-(MOD_CHOICE_SIZE / 2), MOD_CHOICE_SIZE / 2);
cvmod_clock_mod_index = constrain(base_clock_mod_index + dest_mod, 0, 100);
int prob_mod = _calculateMod(CV_DEST_PROB, cv1_val, cv2_val, -50, 50);
cvmod_probability = constrain(base_probability + prob_mod, 0, 100);
int duty_mod = _calculateMod(CV_DEST_DUTY, cv1_val, cv2_val, -50, 50);
cvmod_duty_cycle = constrain(base_duty_cycle + duty_mod, 1, 99);
int offset_mod = _calculateMod(CV_DEST_OFFSET, cv1_val, cv2_val, -50, 50);
cvmod_offset = constrain(base_offset + offset_mod, 0, 99);
int swing_mod = _calculateMod(CV_DEST_SWING, cv1_val, cv2_val, -25, 25);
cvmod_swing = constrain(base_swing + swing_mod, 50, 95);
// After all cvmod values are updated, recalculate clock pulse modifiers.
_recalculatePulses();
}
private:
int _calculateMod(CvDestination dest, int cv1_val, int cv2_val, int min_range,
int max_range) {
int mod1 =
(cv1_dest == dest) ? map(cv1_val, -512, 512, min_range, max_range) : 0;
int mod2 =
(cv2_dest == dest) ? map(cv2_val, -512, 512, min_range, max_range) : 0;
return mod1 + mod2;
}
void _recalculatePulses() {
const uint16_t mod_pulses =
pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]);
_duty_pulses =
max((long)((mod_pulses * (100L - cvmod_duty_cycle)) / 100L), 1L);
_offset_pulses = (long)((mod_pulses * (100L - cvmod_offset)) / 100L);
// Calculate the down beat swing amount.
if (cvmod_swing > 50) {
int shifted_swing = cvmod_swing - 50;
_swing_pulse_amount =
(long)((mod_pulses * (100L - shifted_swing)) / 100L);
} else {
_swing_pulse_amount = 0;
}
}
void setOffset(int off) {
base_offset = constrain(off, 0, 99);
if (!isCvModActive()) {
cvmod_offset = base_offset;
_recalculatePulses();
}
}
void setSwing(int val) {
base_swing = constrain(val, 50, 95);
if (!isCvModActive()) {
cvmod_swing = base_swing;
_recalculatePulses();
}
}
// User-settable base values.
byte base_clock_mod_index;
byte base_probability;
byte base_duty_cycle;
byte base_offset;
byte base_swing;
// Euclidean
void setSteps(int val) {
base_euc_steps = constrain(val, 1, MAX_PATTERN_LEN);
if (cv1_dest != CV_DEST_EUC_STEPS && cv2_dest != CV_DEST_EUC_STEPS) {
pattern.SetSteps(val);
}
}
void setHits(int val) {
base_euc_hits = constrain(val, 1, base_euc_steps);
if (cv1_dest != CV_DEST_EUC_HITS && cv2_dest != CV_DEST_EUC_HITS) {
pattern.SetHits(val);
}
}
// Base value with cv mod applied.
byte cvmod_clock_mod_index;
byte cvmod_probability;
byte cvmod_duty_cycle;
byte cvmod_offset;
byte cvmod_swing;
void setCv1Dest(CvDestination dest) { cv1_dest = dest; }
void setCv2Dest(CvDestination dest) { cv2_dest = dest; }
CvDestination getCv1Dest() const { return cv1_dest; }
CvDestination getCv2Dest() const { return cv2_dest; }
// CV mod configuration
CvDestination cv1_dest;
CvDestination cv2_dest;
// Getters (Get the BASE value for editing or cv modded value for display)
// Mute channel flag
bool mute;
int getProbability(bool withCvMod = false) const { return withCvMod ? cvmod_probability : base_probability; }
int getDutyCycle(bool withCvMod = false) const { return withCvMod ? cvmod_duty_cycle : base_duty_cycle; }
int getOffset(bool withCvMod = false) const { return withCvMod ? cvmod_offset : base_offset; }
int getSwing(bool withCvMod = false) const { return withCvMod ? cvmod_swing : base_swing; }
int getClockMod(bool withCvMod = false) const { return pgm_read_word_near(&CLOCK_MOD[getClockModIndex(withCvMod)]); }
int getClockModIndex(bool withCvMod = false) const { return withCvMod ? cvmod_clock_mod_index : base_clock_mod_index; }
bool isCvModActive() const { return cv1_dest != CV_DEST_NONE || cv2_dest != CV_DEST_NONE; }
byte getSteps(bool withCvMod = false) const { return withCvMod ? pattern.GetSteps() : base_euc_steps; }
byte getHits(bool withCvMod = false) const { return withCvMod ? pattern.GetHits() : base_euc_hits; }
/**
* @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) {
const uint16_t mod_pulses = pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]);
// Conditionally apply swing on down beats.
uint16_t swing_pulses = 0;
if (_swing_pulse_amount > 0 && (tick / mod_pulses) % 2 == 1) {
swing_pulses = _swing_pulse_amount;
}
// Duty cycle high check logic
const uint32_t current_tick_offset = tick + _offset_pulses + swing_pulses;
if (!output.On()) {
// Step check
if (current_tick_offset % mod_pulses == 0) {
bool hit = cvmod_probability >= random(0, 100);
// Euclidean rhythm hit check
switch (pattern.NextStep()) {
case Pattern::REST: // Rest when active or fall back to probability
hit = false;
break;
case Pattern::HIT: // Hit if probability is true
hit &= true;
break;
}
if (hit) {
output.High();
}
}
}
// Duty cycle low check
const uint32_t duty_cycle_end_tick = tick + _duty_pulses + _offset_pulses + swing_pulses;
if (duty_cycle_end_tick % mod_pulses == 0) {
output.Low();
}
}
/**
* @brief Calculate and store cv modded values using bipolar mapping.
* Default to base value if not the current CV destination.
*
* @param cv1_val analog input reading for cv1
* @param cv2_val analog input reading for cv2
*
*/
void applyCvMod(int cv1_val, int cv2_val) {
// Note: This is optimized for cpu performance. This method is called
// from the main loop and stores the cv mod values. This reduces CPU
// cycles inside the internal clock interrupt, which is preferrable.
// However, if RAM usage grows too much, we have an opportunity to
// refactor this to store just the CV read values, and calculate the
// cv mod value per channel inside the getter methods by passing cv
// values. This would reduce RAM usage, but would introduce a
// significant CPU cost, which may have undesirable performance issues.
if (!isCvModActive()) {
cvmod_clock_mod_index = base_clock_mod_index;
cvmod_probability = base_clock_mod_index;
cvmod_duty_cycle = base_clock_mod_index;
cvmod_offset = base_clock_mod_index;
cvmod_swing = base_clock_mod_index;
return;
}
int dest_mod = _calculateMod(CV_DEST_MOD, cv1_val, cv2_val, -(MOD_CHOICE_SIZE / 2), MOD_CHOICE_SIZE / 2);
cvmod_clock_mod_index = constrain(base_clock_mod_index + dest_mod, 0, 100);
int prob_mod = _calculateMod(CV_DEST_PROB, cv1_val, cv2_val, -50, 50);
cvmod_probability = constrain(base_probability + prob_mod, 0, 100);
int duty_mod = _calculateMod(CV_DEST_DUTY, cv1_val, cv2_val, -50, 50);
cvmod_duty_cycle = constrain(base_duty_cycle + duty_mod, 1, 99);
int offset_mod = _calculateMod(CV_DEST_OFFSET, cv1_val, cv2_val, -50, 50);
cvmod_offset = constrain(base_offset + offset_mod, 0, 99);
int swing_mod = _calculateMod(CV_DEST_SWING, cv1_val, cv2_val, -25, 25);
cvmod_swing = constrain(base_swing + swing_mod, 50, 95);
int step_mod = _calculateMod(CV_DEST_EUC_STEPS, cv1_val, cv2_val, 0, MAX_PATTERN_LEN);
pattern.SetSteps(base_euc_steps + step_mod);
int hit_mod = _calculateMod(CV_DEST_EUC_HITS, cv1_val, cv2_val, 0, 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 mod1 = (cv1_dest == dest) ? map(cv1_val, -512, 512, min_range, max_range) : 0;
int mod2 = (cv2_dest == dest) ? map(cv2_val, -512, 512, min_range, max_range) : 0;
return mod1 + mod2;
}
void _recalculatePulses() {
const uint16_t mod_pulses = pgm_read_word_near(&CLOCK_MOD_PULSES[cvmod_clock_mod_index]);
_duty_pulses = max((long)((mod_pulses * (100L - cvmod_duty_cycle)) / 100L), 1L);
_offset_pulses = (long)((mod_pulses * (100L - cvmod_offset)) / 100L);
// Calculate the down beat swing amount.
if (cvmod_swing > 50) {
int shifted_swing = cvmod_swing - 50;
_swing_pulse_amount = (long)((mod_pulses * (100L - shifted_swing)) / 100L);
} else {
_swing_pulse_amount = 0;
}
}
// User-settable base values.
byte base_clock_mod_index;
byte base_probability;
byte base_duty_cycle;
byte base_offset;
byte base_swing;
byte base_euc_steps;
byte base_euc_hits;
// Base value with cv mod applied.
byte cvmod_clock_mod_index;
byte cvmod_probability;
byte cvmod_duty_cycle;
byte cvmod_offset;
byte cvmod_swing;
// CV mod configuration
CvDestination cv1_dest;
CvDestination cv2_dest;
// Euclidean pattern
Pattern pattern;
// Pre-calculated pulse values for ISR performance
uint16_t _duty_pulses;
uint16_t _offset_pulses;
uint16_t _swing_pulse_amount;
// Pre-calculated pulse values for ISR performance
uint16_t _duty_pulses;
uint16_t _offset_pulses;
uint16_t _swing_pulse_amount;
};
#endif // CHANNEL_H
#endif // CHANNEL_H

View File

@ -29,17 +29,24 @@
const uint8_t TEXT_FONT[437] U8G2_FONT_SECTION("velvetscreen") PROGMEM =
"\64\0\2\2\3\3\2\3\4\5\5\0\0\5\0\5\0\0\221\0\0\1\230 \4\200\134%\11\255tT"
"R\271RI(\6\252\334T\31)\7\252\134bJ\12+\7\233\345\322J\0,\5\221T\4-\5\213"
"f\6.\5\211T\2/\6\244\354c\33\60\10\254\354T\64\223\2\61\7\353\354\222\254\6\62\11\254l"
"\66J*\217\0\63\11\254l\66J\32\215\4\64\10\254l\242\34\272\0\65\11\254l\206\336h$\0\66"
"\11\254\354T^\61)\0\67\10\254lF\216u\4\70\11\254\354TL*&\5\71\11\254\354TL;"
"f\6.\5\211T\2/"
"\6\244\354c\33\60\10\254\354T\64\223\2\61\7\353\354\222\254\6\62\11\254l"
"\66J*"
"\217\0\63\11\254l\66J\32\215\4\64\10\254l\242\34\272\0\65\11\254l\206\336h"
"$\0\66"
"\11\254\354T^\61)\0\67\10\254lF\216u\4\70\11\254\354TL*&"
"\5\71\11\254\354TL;"
")\0:\6\231UR\0A\10\254\354T\34S\6B\11\254lV\34)\216\4C\11\254\354T\324\61"
")\0D\10\254lV\64G\2E\10\254l\206\36z\4F\10\254l\206^\71\3G\11\254\354TN"
"\63)\0H\10\254l\242\34S\6I\6\251T\206\0J\10\254\354k\231\24\0K\11\254l\242J\62"
"\63)"
"\0H\10\254l\242\34S\6I\6\251T\206\0J\10\254\354k\231\24\0K\11\254l\242J\62"
"\225\1L\7\254lr{\4M\11\255t\362ZI\353\0N\11\255t\362TI\356\0O\10\254\354T"
"\64\223\2P\11\254lV\34)g\0Q\10\254\354T\264b\12R\10\254lV\34\251\31S\11\254\354"
"\64\223\2P\11\254lV\34)"
"g\0Q\10\254\354T\264b\12R\10\254lV\34\251\31S\11\254\354"
"FF\32\215\4T\7\253dVl\1U\10\254l\242\63)\0V\11\255t\262Ne\312\21W\12\255"
"t\262J*\251.\0X\11\254l\242L*\312\0Y\12\255tr\252\63\312(\2Z\7\253df*"
"\7p\10\255\364V\266\323\2q\7\255\364\216\257\5r\10\253d\242\32*\2t\6\255t\376#w\11"
"\7p\10\255\364V\266\323\2q\7\255\364\216\257\5r\10\253d\242\32*"
"\2t\6\255t\376#w\11"
"\255\364V\245FN\13x\6\233dR\7\0\0\0\4\377\377\0";
/*
@ -49,40 +56,61 @@ const uint8_t TEXT_FONT[437] U8G2_FONT_SECTION("velvetscreen") PROGMEM =
*/
const uint8_t LARGE_FONT[766] U8G2_FONT_SECTION("stk-l") =
"\35\0\4\4\4\5\3\1\6\20\30\0\0\27\0\0\0\1\77\0\0\2\341%'\17;\226\261\245FL"
"\64B\214\30\22\223\220)Bj\10Q\232\214\42R\206\310\210\21d\304\30\32a\254\304\270!\0/\14"
"\272\272\275\311H\321g\343\306\1\60\37|\373\35CJT\20:fW\207\320\210\60\42\304\204\30D\247"
"\214\331\354\20\11%\212\314\0\61\24z\275\245a\244\12\231\71\63b\214\220q\363\377(E\6\62\33|"
"\373\35ShT\20:fl\344\14\211\231\301\306T\71\202#g\371\340\201\1\63\34|\373\35ShT"
"\20:fl\344@r\264\263\222\344,\215\35\42\241\6\225\31\0\64 |\373-!\203\206\214!\62\204"
"\314\220A#\10\215\30\65b\324\210Q\306\354\354\1\213\225\363\1\65\32|\373\15\25[\214\234/\10)"
"\64B\214\30\22\223\220)"
"Bj\10Q\232\214\42R\206\310\210\21d\304\30\32a\254\304\270!\0/\14"
"\272\272\275\311H\321g\343\306\1\60\37|\373\35CJT\20:"
"fW\207\320\210\60\42\304\204\30D\247"
"\214\331\354\20\11%"
"\212\314\0\61\24z\275\245a\244\12\231\71\63b\214\220q\363\377(E\6\62\33|"
"\373\35ShT\20:fl\344\14\211\231\301\306T\71\202#g\371\340\201\1\63\34|"
"\373\35ShT"
"\20:fl\344@r\264\263\222\344,\215\35\42\241\6\225\31\0\64 "
"|\373-!\203\206\214!\62\204"
"\314\220A#\10\215\30\65b\324\210Q\306\354\354\1\213\225\363\1\65\32|"
"\373\15\25[\214\234/\10)"
"Y\61j\350\310Y\32;DB\15*\63\0\66\33}\33\236SiV\14;gt^\230Y\302\202\324"
"\71\273;EbM\252\63\0\67\23|\373\205\25\17R\316\207\344\350p\312\201#\347\35\0\70 |\373"
"\35ShT\20:f\331!\22D\310 :\205\206\10\11B\307\354\354\20\11\65\250\314\0\71\32|\373"
"\71\273;EbM\252\63\0\67\23|\373\205\25\17R\316\207\344\350p\312\201#"
"\347\35\0\70 |\373"
"\35ShT\20:f\331!\22D\310 "
":\205\206\10\11B\307\354\354\20\11\65\250\314\0\71\32|\373"
"\35ShT\20:fg\207H,Q\223r\276\30DB\15*\63\0A\26}\33\246r\247\322P\62"
"j\310\250\21\343\354\335\203\357\354w\3B$}\33\206Dj\226\214\42\61l\304\260\21\303F\14\33\61"
"\212\304\222MF\221\30v\316\236=\10\301b\11\0C\27}\33\236Si\226\20Bft\376O\211\215"
" Db\215\42$\0D\33}\33\206Dj\226\214\32\62l\304\260\21\343\354\177vl\304(\22K\324"
"$\2E\22|\373\205\17R\316KD\30\215\234_>x`\0F\20|\373\205\17R\316\227i\262\31"
"\71\377\22\0I\7s\333\204\77HL\15{\333\205\201\363\377\77|\360`\0N$}\33\6\201\346\314"
"\35;\206\12U\242D&\306\230\30cd\210\221!fF\230\31a(+\314\256\63\67\0O\26}\33"
"\236Si\226\214\32\61\316\376\277\33\61j\310\232Tg\0R\61\216;\6Ek\230\14#\61n\304\270"
"\21\343F\214\33\61n\304\60\22\243\210\60Q\224j\310\260\61\243\306\20\232\325\230QD\206\221\30\67b"
"\334\301\1S\42\216;\236c\211\226\220\42\61n\304\270\21c\307R\232,[\262\203\307\216\65h\16\25"
"\21&\253\320\0T\15}\33\206\17R\15\235\377\377\25\0U\21|\373\205a\366\377\237\215\30\64D\15"
"*\63\0V\26\177\371\205\221\366\377\313\21\343\206\220\42C\25\11r'\313\16\3X)~;\206\201\6"
"\217\221\30\66\204\20\31\42\244\206\14Cg\320$Q\222\6\315!\33\62\212\10\31BD\206\215 v\320"
"\302\1x\24\312\272\205A\206\216\220@c\212\224\31$S\14\262h\0\0\0\0\4\377\377\0";
"j\310\250\21\343\354\335\203\357\354w\3B$}"
"\33\206Dj\226\214\42\61l\304\260\21\303F\14\33\61"
"\212\304\222MF\221\30v\316\236=\10\301b\11\0C\27}"
"\33\236Si\226\20Bft\376O\211\215"
" Db\215\42$\0D\33}\33\206Dj\226\214\32\62l\304\260\21\343\354\177vl\304("
"\22K\324"
"$\2E\22|\373\205\17R\316KD\30\215\234_>x`\0F\20|"
"\373\205\17R\316\227i\262\31"
"\71\377\22\0I\7s\333\204\77HL\15{\333\205\201\363\377\77|\360`\0N$}"
"\33\6\201\346\314"
"\35;\206\12U\242D&\306\230\30cd\210\221!fF\230\31a(+\314\256\63\67\0O\26}"
"\33"
"\236Si\226\214\32\61\316\376\277\33\61j\310\232Tg\0R\61\216;\6Ek\230\14#"
"\61n\304\270"
"\21\343F\214\33\61n\304\60\22\243\210\60Q\224j\310\260\61\243\306\20\232"
"\325\230QD\206\221\30\67b"
"\334\301\1S\42\216;\236c\211\226\220\42\61n\304\270\21c\307R\232,["
"\262\203\307\216\65h\16\25"
"\21&\253\320\0T\15}\33\206\17R\15\235\377\377\25\0U\21|"
"\373\205a\366\377\237\215\30\64D\15"
"*\63\0V\26\177\371\205\221\366\377\313\21\343\206\220\42C\25\11r'"
"\313\16\3X)~;\206\201\6"
"\217\221\30\66\204\20\31\42\244\206\14Cg\320$Q\222\6\315!"
"\33\62\212\10\31BD\206\215 v\320"
"\302\1x\24\312\272\205A\206\216\220@c\212\224\31$"
"S\14\262h\0\0\0\0\4\377\377\0";
#define play_icon_width 14
#define play_icon_height 14
static const unsigned char play_icon[28] PROGMEM = {
0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x7C, 0x00, 0xFC, 0x00, 0xFC, 0x03,
0xFC, 0x0F, 0xFC, 0x0F, 0xFC, 0x03, 0xFC, 0x00, 0x7C, 0x00, 0x3C, 0x00,
0x00, 0x00, 0x00, 0x00};
0x00, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x7C, 0x00, 0xFC, 0x00,
0xFC, 0x03, 0xFC, 0x0F, 0xFC, 0x0F, 0xFC, 0x03, 0xFC, 0x00,
0x7C, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x00, 0x00};
static const unsigned char pause_icon[28] PROGMEM = {
0x00, 0x00, 0x00, 0x00, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E,
0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E,
0x38, 0x0E, 0x00, 0x00};
0x00, 0x00, 0x00, 0x00, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E,
0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E,
0x38, 0x0E, 0x38, 0x0E, 0x38, 0x0E, 0x00, 0x00};
// Constants for screen layout and fonts
constexpr uint8_t SCREEN_CENTER_X = 32;
@ -96,72 +124,103 @@ 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_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,
};
// 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_CV1_DEST,
PARAM_CH_CV2_DEST,
PARAM_CH_LAST,
};
// Helper function to draw centered text
void drawCenteredText(const char* text, int y, const uint8_t* font) {
gravity.display.setFont(font);
int textWidth = gravity.display.getStrWidth(text);
gravity.display.drawStr(SCREEN_CENTER_X - (textWidth / 2), y, text);
void drawCenteredText(const char *text, int y, const uint8_t *font) {
gravity.display.setFont(font);
int textWidth = gravity.display.getStrWidth(text);
gravity.display.drawStr(SCREEN_CENTER_X - (textWidth / 2), y, text);
}
// Helper function to draw right-aligned text
void drawRightAlignedText(const char* text, int y) {
int textWidth = gravity.display.getStrWidth(text);
int drawX = (SCREEN_WIDTH - textWidth) - MENU_BOX_PADDING;
gravity.display.drawStr(drawX, y, text);
void drawRightAlignedText(const char *text, int y) {
int textWidth = gravity.display.getStrWidth(text);
int drawX = (SCREEN_WIDTH - textWidth) - MENU_BOX_PADDING;
gravity.display.drawStr(drawX, y, text);
}
void drawMainSelection() {
gravity.display.setDrawColor(1);
const int tickSize = 3;
const int mainWidth = SCREEN_WIDTH / 2;
const int mainHeight = 49;
gravity.display.drawLine(0, 0, tickSize, 0);
gravity.display.drawLine(0, 0, 0, tickSize);
gravity.display.drawLine(mainWidth, 0, mainWidth - tickSize, 0);
gravity.display.drawLine(mainWidth, 0, mainWidth, tickSize);
gravity.display.drawLine(mainWidth, mainHeight, mainWidth, mainHeight - tickSize);
gravity.display.drawLine(mainWidth, mainHeight, mainWidth - tickSize, mainHeight);
gravity.display.drawLine(0, mainHeight, tickSize, mainHeight);
gravity.display.drawLine(0, mainHeight, 0, mainHeight - tickSize);
gravity.display.setDrawColor(2);
gravity.display.setDrawColor(1);
const int tickSize = 3;
const int mainWidth = SCREEN_WIDTH / 2;
const int mainHeight = 49;
gravity.display.drawLine(0, 0, tickSize, 0);
gravity.display.drawLine(0, 0, 0, tickSize);
gravity.display.drawLine(mainWidth, 0, mainWidth - tickSize, 0);
gravity.display.drawLine(mainWidth, 0, mainWidth, tickSize);
gravity.display.drawLine(mainWidth, mainHeight, mainWidth,
mainHeight - tickSize);
gravity.display.drawLine(mainWidth, mainHeight, mainWidth - tickSize,
mainHeight);
gravity.display.drawLine(0, mainHeight, tickSize, mainHeight);
gravity.display.drawLine(0, mainHeight, 0, mainHeight - tickSize);
gravity.display.setDrawColor(2);
}
void drawMenuItems(String menu_items[], int menu_size) {
// Draw menu items
gravity.display.setFont(TEXT_FONT);
// Draw menu items
gravity.display.setFont(TEXT_FONT);
// Draw selected menu item box
int selectedBoxY = 0;
if (menu_size >= VISIBLE_MENU_ITEMS && app.selected_param == menu_size - 1) {
selectedBoxY = MENU_ITEM_HEIGHT * min(2, app.selected_param);
} else if (app.selected_param > 0) {
selectedBoxY = MENU_ITEM_HEIGHT;
}
// Draw selected menu item box
int selectedBoxY = 0;
if (menu_size >= VISIBLE_MENU_ITEMS && app.selected_param == menu_size - 1) {
selectedBoxY = MENU_ITEM_HEIGHT * min(2, app.selected_param);
} else if (app.selected_param > 0) {
selectedBoxY = MENU_ITEM_HEIGHT;
}
int boxX = MENU_BOX_WIDTH + 1;
int boxY = selectedBoxY + 2;
int boxWidth = MENU_BOX_WIDTH - 1;
int boxHeight = MENU_ITEM_HEIGHT + 1;
int boxX = MENU_BOX_WIDTH + 1;
int boxY = selectedBoxY + 2;
int boxWidth = MENU_BOX_WIDTH - 1;
int boxHeight = MENU_ITEM_HEIGHT + 1;
if (app.editing_param) {
gravity.display.drawBox(boxX, boxY, boxWidth, boxHeight);
drawMainSelection();
} else {
gravity.display.drawFrame(boxX, boxY, boxWidth, boxHeight);
}
if (app.editing_param) {
gravity.display.drawBox(boxX, boxY, boxWidth, boxHeight);
drawMainSelection();
} else {
gravity.display.drawFrame(boxX, boxY, boxWidth, boxHeight);
}
// Draw the visible menu items
int start_index = 0;
if (menu_size >= VISIBLE_MENU_ITEMS && app.selected_param == menu_size - 1) {
start_index = menu_size - VISIBLE_MENU_ITEMS;
} else if (app.selected_param > 0) {
start_index = app.selected_param - 1;
}
// Draw the visible menu items
int start_index = 0;
if (menu_size >= VISIBLE_MENU_ITEMS && app.selected_param == menu_size - 1) {
start_index = menu_size - VISIBLE_MENU_ITEMS;
} else if (app.selected_param > 0) {
start_index = app.selected_param - 1;
}
for (int i = 0; i < min(menu_size, VISIBLE_MENU_ITEMS); ++i) {
int idx = start_index + i;
drawRightAlignedText(menu_items[idx].c_str(), MENU_ITEM_HEIGHT * (i + 1) - 1);
}
for (int i = 0; i < min(menu_size, VISIBLE_MENU_ITEMS); ++i) {
int idx = start_index + i;
drawRightAlignedText(menu_items[idx].c_str(),
MENU_ITEM_HEIGHT * (i + 1) - 1);
}
}
// Visual indicators for main section of screen.
@ -170,266 +229,325 @@ inline void hollowTick() { gravity.display.drawBox(56, 4, 4, 4); }
// Display an indicator when swing percentage matches a musical note.
void swingDivisionMark() {
auto& ch = GetSelectedChannel();
switch (ch.getSwing()) {
case 58: // 1/32nd
case 66: // 1/16th
case 75: // 1/8th
solidTick();
break;
case 54: // 1/32nd tripplet
case 62: // 1/16th tripplet
case 71: // 1/8th tripplet
hollowTick();
break;
}
auto &ch = GetSelectedChannel();
switch (ch.getSwing()) {
case 58: // 1/32nd
case 66: // 1/16th
case 75: // 1/8th
solidTick();
break;
case 54: // 1/32nd tripplet
case 62: // 1/16th tripplet
case 71: // 1/8th tripplet
hollowTick();
break;
}
}
// Human friendly display value for save slot.
String displaySaveSlot(int slot) {
if (slot >= 0 && slot < 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);
}
if (slot >= 0 && slot < StateManager::MAX_SAVE_SLOTS / 2) {
return String("A") + String(slot + 1);
} else if (slot >= StateManager::MAX_SAVE_SLOTS / 2 &&
slot <= StateManager::MAX_SAVE_SLOTS) {
return String("B") + String(slot - (StateManager::MAX_SAVE_SLOTS / 2) + 1);
}
}
// Main display functions
void DisplayMainPage() {
gravity.display.setFontMode(1);
gravity.display.setDrawColor(2);
gravity.display.setFont(TEXT_FONT);
gravity.display.setFontMode(1);
gravity.display.setDrawColor(2);
gravity.display.setFont(TEXT_FONT);
// Display selected editable value
String mainText;
String subText;
// Display selected editable value
String mainText;
String subText;
switch (app.selected_param) {
case PARAM_MAIN_TEMPO:
// Serial MIDI is too unstable to display bpm in real time.
if (app.selected_source == Clock::SOURCE_EXTERNAL_MIDI) {
mainText = F("EXT");
} else {
mainText = String(gravity.clock.Tempo());
}
subText = F("BPM");
break;
case PARAM_MAIN_SOURCE:
mainText = F("EXT");
switch (app.selected_source) {
case Clock::SOURCE_INTERNAL:
mainText = F("INT");
subText = F("CLOCK");
break;
case Clock::SOURCE_EXTERNAL_PPQN_24:
subText = F("24 PPQN");
break;
case Clock::SOURCE_EXTERNAL_PPQN_4:
subText = F("4 PPQN");
break;
case Clock::SOURCE_EXTERNAL_MIDI:
subText = F("MIDI");
break;
}
break;
case PARAM_MAIN_PULSE:
mainText = F("OUT");
switch (app.selected_pulse) {
case Clock::PULSE_NONE:
subText = F("PULSE OFF");
break;
case Clock::PULSE_PPQN_24:
subText = F("24 PPQN PULSE");
break;
case Clock::PULSE_PPQN_4:
subText = F("4 PPQN PULSE");
break;
case Clock::PULSE_PPQN_1:
subText = F("1 PPQN PULSE");
break;
}
break;
case PARAM_MAIN_ENCODER_DIR:
mainText = F("DIR");
subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("REVERSED");
break;
case PARAM_MAIN_SAVE_DATA:
case PARAM_MAIN_LOAD_DATA:
if (app.selected_sub_param == 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");
}
switch (app.selected_param) {
case PARAM_MAIN_TEMPO:
// Serial MIDI is too unstable to display bpm in real time.
if (app.selected_source == Clock::SOURCE_EXTERNAL_MIDI) {
mainText = F("EXT");
} else {
mainText = String(gravity.clock.Tempo());
}
subText = F("BPM");
break;
case PARAM_MAIN_SOURCE:
mainText = F("EXT");
switch (app.selected_source) {
case Clock::SOURCE_INTERNAL:
mainText = F("INT");
subText = F("CLOCK");
break;
case Clock::SOURCE_EXTERNAL_PPQN_24:
subText = F("24 PPQN");
break;
case Clock::SOURCE_EXTERNAL_PPQN_4:
subText = F("4 PPQN");
break;
case Clock::SOURCE_EXTERNAL_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;
}
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) {
case Clock::PULSE_NONE:
subText = F("PULSE OFF");
break;
case Clock::PULSE_PPQN_24:
subText = F("24 PPQN PULSE");
break;
case Clock::PULSE_PPQN_4:
subText = F("4 PPQN PULSE");
break;
case Clock::PULSE_PPQN_1:
subText = F("1 PPQN PULSE");
break;
}
break;
case PARAM_MAIN_ENCODER_DIR:
mainText = F("DIR");
subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("REVERSED");
break;
case PARAM_MAIN_ROTATE_DISP:
mainText = F("DISP");
subText = app.selected_sub_param == 0 ? F("DEFAULT") : F("ROTATED");
break;
case PARAM_MAIN_SAVE_DATA:
case PARAM_MAIN_LOAD_DATA:
if (app.selected_sub_param == StateManager::MAX_SAVE_SLOTS) {
mainText = F("x");
subText = F("BACK TO MAIN");
} else {
// Indicate currently active slot.
if (app.selected_sub_param == app.selected_save_slot) {
solidTick();
}
mainText = displaySaveSlot(app.selected_sub_param);
subText = (app.selected_param == PARAM_MAIN_SAVE_DATA)
? F("SAVE TO SLOT")
: F("LOAD FROM SLOT");
}
break;
case PARAM_MAIN_RESET_STATE:
if (app.selected_sub_param == 0) {
mainText = F("RST");
subText = F("RESET ALL");
} else {
mainText = F("x");
subText = F("BACK TO MAIN");
}
break;
case PARAM_MAIN_FACTORY_RESET:
if (app.selected_sub_param == 0) {
mainText = F("DEL");
subText = F("FACTORY RESET");
} else {
mainText = F("x");
subText = F("BACK TO MAIN");
}
break;
}
drawCenteredText(mainText.c_str(), MAIN_TEXT_Y, LARGE_FONT);
drawCenteredText(subText.c_str(), SUB_TEXT_Y, TEXT_FONT);
drawCenteredText(mainText.c_str(), MAIN_TEXT_Y, LARGE_FONT);
drawCenteredText(subText.c_str(), SUB_TEXT_Y, TEXT_FONT);
// Draw Main Page menu items
String menu_items[PARAM_MAIN_LAST] = {F("TEMPO"), F("SOURCE"), F("PULSE OUT"), F("ENCODER DIR"), F("SAVE"), F("LOAD"), F("RESET")};
drawMenuItems(menu_items, PARAM_MAIN_LAST);
// Draw Main Page menu items
String menu_items[PARAM_MAIN_LAST] = {
F("TEMPO"), F("RUN"), F("RST"), F("SOURCE"),
F("PULSE OUT"), F("ENCODER DIR"), F("ROTATE DISP"), F("SAVE"),
F("LOAD"), F("RESET"), F("ERASE")};
drawMenuItems(menu_items, PARAM_MAIN_LAST);
}
void DisplayChannelPage() {
auto& ch = GetSelectedChannel();
auto &ch = GetSelectedChannel();
gravity.display.setFontMode(1);
gravity.display.setDrawColor(2);
gravity.display.setFontMode(1);
gravity.display.setDrawColor(2);
// Display selected editable value
String mainText;
String subText;
// Display selected editable value
String mainText;
String subText;
// When editing a param, just show the base value. When not editing show
// the value with cv mod.
bool withCvMod = !app.editing_param;
// When editing a param, just show the base value. When not editing show
// the value with cv mod.
bool withCvMod = !app.editing_param;
switch (app.selected_param) {
case PARAM_CH_MOD: {
int mod_value = ch.getClockMod(withCvMod);
if (mod_value > 1) {
mainText = F("/");
mainText += String(mod_value);
subText = F("DIVIDE");
} else {
mainText = F("x");
mainText += String(abs(mod_value));
subText = F("MULTIPLY");
}
break;
}
case PARAM_CH_PROB:
mainText = String(ch.getProbability(withCvMod)) + F("%");
subText = F("HIT CHANCE");
break;
case PARAM_CH_DUTY:
mainText = String(ch.getDutyCycle(withCvMod)) + F("%");
subText = F("PULSE WIDTH");
break;
case PARAM_CH_OFFSET:
mainText = String(ch.getOffset(withCvMod)) + F("%");
subText = F("SHIFT HIT");
break;
case PARAM_CH_SWING:
ch.getSwing() == 50
? mainText = F("OFF")
: mainText = String(ch.getSwing(withCvMod)) + F("%");
subText = "DOWN BEAT";
swingDivisionMark();
break;
case PARAM_CH_EUC_STEPS:
mainText = String(ch.getSteps(withCvMod));
subText = "EUCLID STEPS";
break;
case PARAM_CH_EUC_HITS:
mainText = String(ch.getHits(withCvMod));
subText = "EUCLID HITS";
break;
case PARAM_CH_CV1_DEST:
case PARAM_CH_CV2_DEST: {
mainText = (app.selected_param == PARAM_CH_CV1_DEST) ? F("CV1") : F("CV2");
switch ((app.selected_param == PARAM_CH_CV1_DEST) ? ch.getCv1Dest() : ch.getCv2Dest()) {
case CV_DEST_NONE:
subText = F("NONE");
break;
case CV_DEST_MOD:
subText = F("CLOCK MOD");
break;
case CV_DEST_PROB:
subText = F("PROBABILITY");
break;
case CV_DEST_DUTY:
subText = F("DUTY CYCLE");
break;
case CV_DEST_OFFSET:
subText = F("OFFSET");
break;
case CV_DEST_SWING:
subText = F("SWING");
break;
case CV_DEST_EUC_STEPS:
subText = F("EUCLID STEPS");
break;
case CV_DEST_EUC_HITS:
subText = F("EUCLID HITS");
break;
}
break;
}
switch (app.selected_param) {
case PARAM_CH_MOD: {
int mod_value = ch.getClockMod(withCvMod);
if (mod_value > 1) {
mainText = F("/");
mainText += String(mod_value);
subText = F("DIVIDE");
} else {
mainText = F("x");
mainText += String(abs(mod_value));
subText = F("MULTIPLY");
}
break;
}
case PARAM_CH_PROB:
mainText = String(ch.getProbability(withCvMod)) + F("%");
subText = F("HIT CHANCE");
break;
case PARAM_CH_DUTY:
mainText = String(ch.getDutyCycle(withCvMod)) + F("%");
subText = F("PULSE WIDTH");
break;
case PARAM_CH_OFFSET:
mainText = String(ch.getOffset(withCvMod)) + F("%");
subText = F("SHIFT HIT");
break;
case PARAM_CH_SWING:
ch.getSwing() == 50 ? mainText = F("OFF")
: mainText = String(ch.getSwing(withCvMod)) + F("%");
subText = "DOWN BEAT";
swingDivisionMark();
break;
case PARAM_CH_CV1_DEST:
case PARAM_CH_CV2_DEST: {
mainText = (app.selected_param == PARAM_CH_CV1_DEST) ? F("CV1") : F("CV2");
switch ((app.selected_param == PARAM_CH_CV1_DEST) ? ch.getCv1Dest()
: ch.getCv2Dest()) {
case CV_DEST_NONE:
subText = F("NONE");
break;
case CV_DEST_MOD:
subText = F("CLOCK MOD");
break;
case CV_DEST_PROB:
subText = F("PROBABILITY");
break;
case CV_DEST_DUTY:
subText = F("DUTY CYCLE");
break;
case CV_DEST_OFFSET:
subText = F("OFFSET");
break;
case CV_DEST_SWING:
subText = F("SWING");
break;
}
break;
}
}
drawCenteredText(mainText.c_str(), MAIN_TEXT_Y, LARGE_FONT);
drawCenteredText(subText.c_str(), SUB_TEXT_Y, TEXT_FONT);
drawCenteredText(mainText.c_str(), MAIN_TEXT_Y, LARGE_FONT);
drawCenteredText(subText.c_str(), SUB_TEXT_Y, TEXT_FONT);
// Draw Channel Page menu items
String menu_items[PARAM_CH_LAST] = {
F("MOD"), F("PROBABILITY"), F("DUTY"), F("OFFSET"), F("SWING"), F("EUCLID STEPS"),
F("EUCLID HITS"), F("CV1 MOD"), F("CV2 MOD")};
drawMenuItems(menu_items, PARAM_CH_LAST);
// Draw Channel Page menu items
String menu_items[PARAM_CH_LAST] = {
F("MOD"), F("PROBABILITY"), F("DUTY"), F("OFFSET"),
F("SWING"), F("CV1 MOD"), F("CV2 MOD")};
drawMenuItems(menu_items, PARAM_CH_LAST);
}
void DisplaySelectedChannel() {
int boxX = CHANNEL_BOX_WIDTH;
int boxY = CHANNEL_BOXES_Y;
int boxWidth = CHANNEL_BOX_WIDTH;
int boxHeight = CHANNEL_BOX_HEIGHT;
int textOffset = 7; // Half of font width
int boxX = CHANNEL_BOX_WIDTH;
int boxY = CHANNEL_BOXES_Y;
int boxWidth = CHANNEL_BOX_WIDTH;
int boxHeight = CHANNEL_BOX_HEIGHT;
int textOffset = 7; // Half of font width
// Draw top and right side of frame.
gravity.display.drawHLine(1, boxY, SCREEN_WIDTH - 2);
gravity.display.drawVLine(SCREEN_WIDTH - 2, boxY, boxHeight);
// Draw top and right side of frame.
gravity.display.drawHLine(1, boxY, SCREEN_WIDTH - 2);
gravity.display.drawVLine(SCREEN_WIDTH - 2, boxY, boxHeight);
for (int i = 0; i < Gravity::OUTPUT_COUNT + 1; i++) {
// Draw box frame or filled selected box.
gravity.display.setDrawColor(1);
(app.selected_channel == i)
? gravity.display.drawBox(i * boxWidth, boxY, boxWidth, boxHeight)
: gravity.display.drawVLine(i * boxWidth, boxY, boxHeight);
for (int i = 0; i < Gravity::OUTPUT_COUNT + 1; i++) {
// Draw box frame or filled selected box.
gravity.display.setDrawColor(1);
(app.selected_channel == i)
? gravity.display.drawBox(i * boxWidth, boxY, boxWidth, boxHeight)
: gravity.display.drawVLine(i * boxWidth, boxY, boxHeight);
// Draw clock status icon or each channel number.
gravity.display.setDrawColor(2);
if (i == 0) {
gravity.display.setBitmapMode(1);
auto icon = gravity.clock.IsPaused() ? pause_icon : play_icon;
gravity.display.drawXBMP(2, boxY, play_icon_width, play_icon_height, icon);
} else {
gravity.display.setFont(TEXT_FONT);
gravity.display.setCursor((i * boxWidth) + textOffset, SCREEN_HEIGHT - 3);
gravity.display.print(i);
}
// Draw clock status icon or each channel number.
gravity.display.setDrawColor(2);
if (i == 0) {
gravity.display.setBitmapMode(1);
auto icon = gravity.clock.IsPaused() ? pause_icon : play_icon;
gravity.display.drawXBMP(2, boxY, play_icon_width, play_icon_height,
icon);
} else {
gravity.display.setFont(TEXT_FONT);
gravity.display.setCursor((i * boxWidth) + textOffset, SCREEN_HEIGHT - 3);
gravity.display.print(i);
}
}
}
void UpdateDisplay() {
app.refresh_screen = false;
gravity.display.firstPage();
do {
if (app.selected_channel == 0) {
DisplayMainPage();
} else {
DisplayChannelPage();
}
// Global channel select UI.
DisplaySelectedChannel();
} while (gravity.display.nextPage());
app.refresh_screen = false;
gravity.display.firstPage();
do {
if (app.selected_channel == 0) {
DisplayMainPage();
} else {
DisplayChannelPage();
}
// Global channel select UI.
DisplaySelectedChannel();
} while (gravity.display.nextPage());
}
#endif // DISPLAY_H
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

View File

@ -15,151 +15,225 @@
#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.1BETA1"; // NOTE: This should match the version in the
// library.properties file.
// Number of available save slots.
const byte StateManager::MAX_SAVE_SLOTS = 10;
const byte StateManager::TRANSIENT_SLOT = 10;
// Define the minimum amount of time between EEPROM writes.
const unsigned long StateManager::SAVE_DELAY_MS = 2000;
// Calculate the starting address for EepromData, leaving space for metadata.
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) {}
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.
// 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;
}
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;
}
// 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);
}
interrupts();
return success;
}
bool StateManager::loadData(AppState& app, byte slot_index) {
if (slot_index >= MAX_SAVE_SLOTS) return false;
bool StateManager::loadData(AppState &app, byte slot_index) {
// Check if slot_index is within max range + 1 for transient.
if (slot_index >= MAX_SAVE_SLOTS + 1)
return false;
_loadState(app, slot_index);
noInterrupts();
return true;
// 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;
interrupts();
return true;
}
void StateManager::saveData(const AppState& app) {
if (app.selected_save_slot >= MAX_SAVE_SLOTS) return;
// 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) {
interrupts();
return;
}
_saveState(app, app.selected_save_slot);
_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::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) {
noInterrupts();
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;
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;
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();
}
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
app.channel[i].Init();
}
_isDirty = false;
// Load global settings from Metadata
_loadMetadata(app);
_isDirty = false;
interrupts();
}
void StateManager::markDirty() {
_isDirty = true;
_lastChangeTime = millis();
_isDirty = true;
_lastChangeTime = millis();
}
// Erases all data in the EEPROM by writing 0 to every address.
void StateManager::factoryReset(AppState &app) {
noInterrupts();
for (unsigned int i = 0; i < EEPROM.length(); i++) {
EEPROM.write(i, 0);
}
// Initialize eeprom and save default patter to all save slots.
_saveMetadata(app);
reset(app);
for (int i = 0; i < MAX_SAVE_SLOTS; i++) {
app.selected_save_slot = i;
_saveState(app, i);
}
_saveState(app, TRANSIENT_SLOT);
interrupts();
}
bool StateManager::_isDataValid() {
Metadata load_meta;
EEPROM.get(0, 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;
Metadata metadata;
EEPROM.get(METADATA_START_ADDR, metadata);
bool name_match = (strcmp(metadata.sketch_name, SKETCH_NAME) == 0);
bool version_match = (strcmp(metadata.version, SEMANTIC_VERSION) == 0);
return name_match && version_match;
}
void StateManager::_saveState(const AppState& app, byte slot_index) {
if (app.selected_save_slot >= MAX_SAVE_SLOTS) return;
void StateManager::_saveState(const AppState &app, byte slot_index) {
// Check if slot_index is within max range + 1 for transient.
if (app.selected_save_slot >= MAX_SAVE_SLOTS + 1)
return;
noInterrupts();
static EepromData save_data;
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<byte>(app.selected_source);
save_data.selected_pulse = static_cast<byte>(app.selected_pulse);
save_data.selected_save_slot = app.selected_save_slot;
save_data.tempo = app.tempo;
save_data.selected_param = app.selected_param;
save_data.selected_channel = app.selected_channel;
save_data.selected_source = static_cast<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;
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
const auto& ch = app.channel[i];
auto& save_ch = save_data.channel_data[i];
save_ch.base_clock_mod_index = ch.getClockModIndex(false);
save_ch.base_probability = ch.getProbability(false);
save_ch.base_duty_cycle = ch.getDutyCycle(false);
save_ch.base_offset = ch.getOffset(false);
save_ch.base_swing = ch.getSwing(false);
save_ch.base_euc_steps = ch.getSteps(false);
save_ch.base_euc_hits = ch.getHits(false);
save_ch.cv1_dest = static_cast<byte>(ch.getCv1Dest());
save_ch.cv2_dest = static_cast<byte>(ch.getCv2Dest());
}
// TODO: break this out into a separate function. Save State should be
// broken out into global / per-channel save methods. When saving via
// "update" only save state for the current channel since other channels
// will not have changed when saving user edits.
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
const auto &ch = app.channel[i];
auto &save_ch = save_data.channel_data[i];
save_ch.base_clock_mod_index = ch.getClockModIndex(false);
save_ch.base_probability = ch.getProbability(false);
save_ch.base_duty_cycle = ch.getDutyCycle(false);
save_ch.base_offset = ch.getOffset(false);
save_ch.cv1_dest = static_cast<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();
int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData));
EEPROM.put(address, save_data);
}
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);
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;
// 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<Clock::Source>(load_data.selected_source);
app.selected_pulse = static_cast<Clock::Pulse>(load_data.selected_pulse);
app.selected_save_slot = slot_index;
static EepromData load_data;
int address = EEPROM_DATA_START_ADDR + (slot_index * sizeof(EepromData));
EEPROM.get(address, load_data);
for (int i = 0; i < Gravity::OUTPUT_COUNT; i++) {
auto& ch = app.channel[i];
const auto& saved_ch_state = load_data.channel_data[i];
// Restore app state from loaded data.
app.tempo = load_data.tempo;
app.selected_param = load_data.selected_param;
app.selected_channel = load_data.selected_channel;
app.selected_source = static_cast<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;
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<CvDestination>(saved_ch_state.cv1_dest));
ch.setCv2Dest(static_cast<CvDestination>(saved_ch_state.cv2_dest));
}
interrupts();
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.setCv1Dest(static_cast<CvDestination>(saved_ch_state.cv1_dest));
ch.setCv2Dest(static_cast<CvDestination>(saved_ch_state.cv2_dest));
}
}
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();
void StateManager::_saveMetadata(const AppState &app) {
Metadata current_meta;
strcpy(current_meta.sketch_name, SKETCH_NAME);
strcpy(current_meta.version, SEMANTIC_VERSION);
// Global user settings
current_meta.selected_save_slot = app.selected_save_slot;
current_meta.encoder_reversed = app.encoder_reversed;
current_meta.rotate_display = app.rotate_display;
EEPROM.put(METADATA_START_ADDR, current_meta);
}
void StateManager::_loadMetadata(AppState &app) {
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;
}

View File

@ -13,82 +13,85 @@
#define SAVE_STATE_H
#include <Arduino.h>
#include <gravity.h>
#include <libGravity.h>
// Forward-declare AppState to avoid circular dependencies.
struct AppState;
// Define the constants for the current firmware.
const char SKETCH_NAME[] = "Gravity";
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.
* @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:
StateManager();
public:
static const char SKETCH_NAME[];
static const char SEMANTIC_VERSION[];
static const byte MAX_SAVE_SLOTS;
static const byte TRANSIENT_SLOT;
// Populate the AppState instance with values from EEPROM if they exist.
bool initialize(AppState& app);
// Load data from specified slot.
bool loadData(AppState& app, byte slot_index);
// Save data to specified slot.
void saveData(const AppState& app);
// Reset AppState instance back to default values.
void reset(AppState& app);
// Call from main loop, check if state has changed and needs to be saved.
void update(const AppState& app);
// Indicate that state has changed and we should save.
void markDirty();
StateManager();
// This struct holds the data that identifies the firmware version.
struct Metadata {
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_swing;
byte base_euc_steps;
byte base_euc_hits;
byte cv1_dest; // Cast the CvDestination enum as a byte for storage
byte cv2_dest; // Cast the CvDestination enum as a byte for storage
};
// This struct holds all the parameters we want to save.
struct EepromData {
int tempo;
bool encoder_reversed;
byte selected_param;
byte selected_channel;
byte selected_source;
byte selected_pulse;
byte selected_save_slot;
ChannelState channel_data[Gravity::OUTPUT_COUNT];
};
// Populate the AppState instance with values from EEPROM if they exist.
bool initialize(AppState &app);
// Load data from specified slot.
bool loadData(AppState &app, byte slot_index);
// Save data to specified slot.
void saveData(const AppState &app);
// Reset AppState instance back to default values.
void reset(AppState &app);
// Call from main loop, check if state has changed and needs to be saved.
void update(const AppState &app);
// Indicate that state has changed and we should save.
void markDirty();
// Erase all data stored in the EEPROM.
void factoryReset(AppState &app);
private:
bool _isDataValid();
void _saveMetadata();
void _saveState(const AppState& app, byte slot_index);
void _loadState(AppState& app, byte slot_index);
// This struct holds the data that identifies the firmware version.
struct Metadata {
char sketch_name[16];
char version[16];
// Additional global/hardware settings
byte selected_save_slot;
bool encoder_reversed;
bool rotate_display;
};
struct ChannelState {
byte base_clock_mod_index;
byte base_probability;
byte base_duty_cycle;
byte base_offset;
byte cv1_dest; // Cast the CvDestination enum as a byte for storage
byte cv2_dest; // Cast the CvDestination enum as a byte for storage
};
// This struct holds all the parameters we want to save.
struct EepromData {
int tempo;
byte selected_param;
byte selected_channel;
byte selected_source;
byte selected_pulse;
byte cv_run;
byte cv_reset;
ChannelState channel_data[Gravity::OUTPUT_COUNT];
};
bool _isDirty;
unsigned long _lastChangeTime;
private:
bool _isDataValid();
void _saveMetadata(const AppState &app);
void _loadMetadata(AppState &app);
void _saveState(const AppState &app, byte slot_index);
void _loadState(AppState &app, byte slot_index);
static const unsigned long SAVE_DELAY_MS;
static const int METADATA_START_ADDR;
static const int EEPROM_DATA_START_ADDR;
bool _isDirty;
unsigned long _lastChangeTime;
};
#endif // SAVE_STATE_H
#endif // SAVE_STATE_H

9
library.properties Normal file
View File

@ -0,0 +1,9 @@
version=2.0.1beta1
author=Adam Wonak
maintainer=awonak <github.com/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

104
src/analog_input.h Normal file
View File

@ -0,0 +1,104 @@
/**
* @file analog_input.h
* @author Adam Wonak (https://github.com/awonak)
* @brief Class for interacting with analog inputs.
* @version 0.1
* @date 2025-05-23
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
*/
#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:
static const int GATE_THRESHOLD = 0;
AnalogInput() {}
~AnalogInput() {}
/**
* Initializes a analog input object.
*
* @param pin gpio pin for the analog input.
*/
void Init(uint8_t pin) {
pinMode(pin, INPUT);
pin_ = pin;
}
/**
* Read the value of the analog input and set instance state.
*
*/
void Process() {
old_read_ = read_;
int raw = analogRead(pin_);
read_ = map(raw, 0, MAX_INPUT, low_, high_);
read_ = constrain(read_ - offset_, -512, 512);
if (inverted_)
read_ = -read_;
}
// Set calibration values.
void AdjustCalibrationLow(int amount) { low_ += 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); }
/**
* Checks for a rising edge transition across a threshold.
*
* @param threshold The value that the input must cross.
* @return True if the value just crossed the threshold from below, false
* otherwise.
*/
inline bool IsRisingEdge(int16_t threshold) const {
bool was_high = old_read_ > threshold;
bool is_high = read_ > threshold;
return is_high && !was_high;
}
private:
uint8_t pin_;
int16_t read_;
uint16_t old_read_;
// calibration values.
int offset_ = 0;
int low_ = CALIBRATED_LOW;
int high_ = CALIBRATED_HIGH;
bool inverted_ = false;
};
#endif

186
src/clock.h Normal file
View File

@ -0,0 +1,186 @@
/**
* @file clock.h
* @author Adam Wonak (https://github.com/awonak)
* @brief Wrapper Class for clock timing functions.
* @version 0.1
* @date 2025-05-04
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
*/
#ifndef CLOCK_H
#define CLOCK_H
#include <NeoHWSerial.h>
#include "peripherials.h"
#include "uClock/uClock.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
typedef void (*ExtCallback)(void);
static ExtCallback extUserCallback = nullptr;
static void serialEventNoop(uint8_t msg, uint8_t status) {}
class Clock {
public:
static constexpr int DEFAULT_TEMPO = 120;
enum Source {
SOURCE_INTERNAL,
SOURCE_EXTERNAL_PPQN_24,
SOURCE_EXTERNAL_PPQN_4,
SOURCE_EXTERNAL_PPQN_2,
SOURCE_EXTERNAL_PPQN_1,
SOURCE_EXTERNAL_MIDI,
SOURCE_LAST,
};
enum Pulse {
PULSE_NONE,
PULSE_PPQN_1,
PULSE_PPQN_4,
PULSE_PPQN_24,
PULSE_LAST,
};
void Init() {
NeoSerial.begin(31250);
// Initialize the clock library
uClock.init();
uClock.setClockMode(uClock.INTERNAL_CLOCK);
uClock.setOutputPPQN(uClock.PPQN_96);
uClock.setTempo(DEFAULT_TEMPO);
// MIDI events.
uClock.setOnClockStart(sendMIDIStart);
uClock.setOnClockStop(sendMIDIStop);
uClock.setOnSync24(sendMIDIClock);
uClock.start();
}
// Handle external clock tick and call user callback when receiving clock
// trigger (PPQN_4, PPQN_24, or MIDI).
void AttachExtHandler(void (*callback)()) {
extUserCallback = callback;
attachInterrupt(digitalPinToInterrupt(EXT_PIN), callback, RISING);
}
// Internal PPQN96 callback for all clock timer operations.
void AttachIntHandler(void (*callback)(uint32_t)) {
uClock.setOnOutputPPQN(callback);
}
// Set the source of the clock mode.
void SetSource(Source source) {
bool was_playing = !IsPaused();
uClock.stop();
// If we are changing the source from MIDI, disable the serial interrupt
// handler.
if (source_ == SOURCE_EXTERNAL_MIDI) {
NeoSerial.attachInterrupt(serialEventNoop);
}
source_ = source;
switch (source) {
case SOURCE_INTERNAL:
uClock.setClockMode(uClock.INTERNAL_CLOCK);
break;
case SOURCE_EXTERNAL_PPQN_24:
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
uClock.setInputPPQN(uClock.PPQN_24);
break;
case SOURCE_EXTERNAL_PPQN_4:
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
uClock.setInputPPQN(uClock.PPQN_4);
break;
case SOURCE_EXTERNAL_PPQN_2:
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
uClock.setInputPPQN(uClock.PPQN_2);
break;
case SOURCE_EXTERNAL_PPQN_1:
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
uClock.setInputPPQN(uClock.PPQN_1);
break;
case SOURCE_EXTERNAL_MIDI:
uClock.setClockMode(uClock.EXTERNAL_CLOCK);
uClock.setInputPPQN(uClock.PPQN_24);
NeoSerial.attachInterrupt(onSerialEvent);
break;
}
if (was_playing) {
uClock.start();
}
}
// Return true if the current selected source is externl (PPQN_4, PPQN_24, or
// MIDI).
bool ExternalSource() {
return uClock.getClockMode() == uClock.EXTERNAL_CLOCK;
}
// Return true if the current selected source is the internal master clock.
bool InternalSource() {
return uClock.getClockMode() == uClock.INTERNAL_CLOCK;
}
// Returns the current BPM tempo.
int Tempo() { return uClock.getTempo(); }
// Set the clock tempo to a int between 1 and 400.
void SetTempo(int tempo) { return uClock.setTempo(tempo); }
// Record an external clock tick received to process external/internal
// syncronization.
void Tick() { uClock.clockMe(); }
// Start the internal clock.
void Start() { uClock.start(); }
// Stop internal clock clock.
void Stop() { uClock.stop(); }
// Reset all clock counters to 0.
void Reset() { uClock.resetCounters(); }
// Returns true if the clock is not running.
bool IsPaused() { return uClock.clock_state == uClock.PAUSED; }
private:
Source source_ = SOURCE_INTERNAL;
static void onSerialEvent(uint8_t msg, uint8_t status) {
// Note: uClock start and stop will echo to MIDI.
switch (msg) {
case MIDI_CLOCK:
if (extUserCallback) {
extUserCallback();
}
break;
case MIDI_STOP:
uClock.stop();
sendMIDIStop();
break;
case MIDI_START:
case MIDI_CONTINUE:
uClock.start();
sendMIDIStart();
break;
}
}
static void sendMIDIStart() { NeoSerial.write(MIDI_START); }
static void sendMIDIStop() { NeoSerial.write(MIDI_STOP); }
static void sendMIDIClock(uint32_t tick) { NeoSerial.write(MIDI_CLOCK); }
};
#endif

96
src/digital_output.h Normal file
View File

@ -0,0 +1,96 @@
/**
* @file digital_output.h
* @author Adam Wonak (https://github.com/awonak)
* @brief Class for interacting with trigger / gate outputs.
* @version 0.1
* @date 2025-04-17
*
* @copyright MIT - (c) 2025 - Adam Wonak - adam.wonak@gmail.com
*
*/
#ifndef DIGITAL_OUTPUT_H
#define DIGITAL_OUTPUT_H
#include <Arduino.h>
const byte DEFAULT_TRIGGER_DURATION_MS = 5;
class DigitalOutput {
public:
/**
* Initializes an CV Output paired object.
*
* @param cv_pin gpio pin for the cv output
*/
void Init(uint8_t cv_pin) {
pinMode(cv_pin, OUTPUT); // Gate/Trigger Output
cv_pin_ = cv_pin;
trigger_duration_ = DEFAULT_TRIGGER_DURATION_MS;
}
/**
* Set the trigger duration in miliseconds.
*
* @param duration_ms trigger duration in miliseconds
*/
void SetTriggerDuration(uint8_t duration_ms) {
trigger_duration_ = duration_ms;
}
/**
* Turn the CV and LED on or off according to the input state.
*
* @param state Arduino digital HIGH or LOW values.
*/
inline void Update(uint8_t state) {
if (state == HIGH)
High(); // Rising
if (state == LOW)
Low(); // Falling
}
// Sets the cv output HIGH to about 5v.
inline void High() { update(HIGH); }
// Sets the cv output LOW to 0v.
inline void Low() { update(LOW); }
/**
* Begin a Trigger period for this output.
*/
inline void Trigger() {
update(HIGH);
last_triggered_ = millis();
}
/**
* Return a bool representing the on/off state of the output.
*/
inline void Process() {
// If trigger is HIGH and the trigger duration time has elapsed, set the
// output low.
if (on_ && (millis() - last_triggered_) >= trigger_duration_) {
update(LOW);
}
}
/**
* Return a bool representing the on/off state of the output.
*
* @return true if current cv state is high, false if current cv state is low
*/
inline bool On() { return on_; }
private:
unsigned long last_triggered_;
uint8_t trigger_duration_;
uint8_t cv_pin_;
bool on_;
void update(uint8_t state) {
digitalWrite(cv_pin_, state);
on_ = state == HIGH;
}
};
#endif

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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 <Arduino.h>
#include <inttypes.h>
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__ */