feat: Introduce global menu for clock and CV destination parameters, integrate CV modulation, and refactor display rendering.

This commit is contained in:
2026-03-17 21:11:47 -07:00
parent 8b4b96e65a
commit a70b567a33
2 changed files with 456 additions and 107 deletions

View File

@ -23,6 +23,37 @@ const unsigned long SAVE_DELAY_MS = 5000;
bool eeprom_needs_save = false;
unsigned long last_param_change = 0;
// Menus and State
enum MenuPage {
PAGE_PATTERN = 0,
PAGE_GLOBAL = 1,
};
enum GlobalParam {
PARAM_GLOBAL_CLK_SRC = 0,
PARAM_GLOBAL_BPM = 1,
PARAM_GLOBAL_CV1_DEST = 2,
PARAM_GLOBAL_CV2_DEST = 3,
PARAM_GLOBAL_LAST = 4
};
enum CvDest {
CV_DEST_NONE = 0,
CV_DEST_KICK_DENS = 1,
CV_DEST_SNARE_DENS = 2,
CV_DEST_HHAT_DENS = 3,
CV_DEST_CHAOS = 4,
CV_DEST_MAP_X = 5,
CV_DEST_MAP_Y = 6,
CV_DEST_LAST = 7
};
MenuPage current_page = PAGE_PATTERN;
GlobalParam current_global_param = PARAM_GLOBAL_CLK_SRC;
CvDest cv1_dest = CV_DEST_MAP_X;
CvDest cv2_dest = CV_DEST_CHAOS;
Clock::Source selected_source = Clock::SOURCE_INTERNAL;
// UI & Navigation
enum SelectedParam {
PARAM_KICK_DENS = 0,
@ -42,7 +73,6 @@ const unsigned long REDRAW_DELAY_MS = 30; // ~33fps limit
// Sequencer State
int current_step = 0;
bool is_playing = false;
// Engine Parameters (0-255)
int inst_density[3] = {128, 128,
@ -104,6 +134,12 @@ void LoadState() {
EEPROM.get(EEPROM_MAP_X, map_x);
EEPROM.get(EEPROM_MAP_Y, map_y);
EEPROM.get(EEPROM_CHAOS, chaos_amount);
// We can just add read for newly added properties.
// Wait until later to strictly map out addresses manually
// or just let EEPROM.get/put figure it out relative to addresses.
// For simplicity, let's keep it safe. We will increment the EEPROM_INIT_FLAG
// to 0xAD and append the new values after EEPROM_CHAOS.
}
}
@ -148,13 +184,37 @@ void ProcessSequencerTick(uint32_t tick) {
// Handle new 16th note step
if (tick % PULSES_PER_16TH == 0) {
int mod_map_x = constrain(map_x + (cv1_val / 2), 0, 255);
int active_chaos = constrain(chaos_amount + (cv2_val / 2), 0, 255);
int active_map_x = map_x;
int active_map_y = map_y;
int active_chaos = chaos_amount;
int active_dens[3] = { inst_density[0], inst_density[1], inst_density[2] };
// Apply CV1
switch (cv1_dest) {
case CV_DEST_KICK_DENS: active_dens[0] = constrain(active_dens[0] + (cv1_val / 4), 0, 255); break;
case CV_DEST_SNARE_DENS: active_dens[1] = constrain(active_dens[1] + (cv1_val / 4), 0, 255); break;
case CV_DEST_HHAT_DENS: active_dens[2] = constrain(active_dens[2] + (cv1_val / 4), 0, 255); break;
case CV_DEST_CHAOS: active_chaos = constrain(active_chaos + (cv1_val / 4), 0, 255); break;
case CV_DEST_MAP_X: active_map_x = constrain(active_map_x + (cv1_val / 4), 0, 255); break;
case CV_DEST_MAP_Y: active_map_y = constrain(active_map_y + (cv1_val / 4), 0, 255); break;
default: break;
}
// Apply CV2
switch (cv2_dest) {
case CV_DEST_KICK_DENS: active_dens[0] = constrain(active_dens[0] + (cv2_val / 4), 0, 255); break;
case CV_DEST_SNARE_DENS: active_dens[1] = constrain(active_dens[1] + (cv2_val / 4), 0, 255); break;
case CV_DEST_HHAT_DENS: active_dens[2] = constrain(active_dens[2] + (cv2_val / 4), 0, 255); break;
case CV_DEST_CHAOS: active_chaos = constrain(active_chaos + (cv2_val / 4), 0, 255); break;
case CV_DEST_MAP_X: active_map_x = constrain(active_map_x + (cv2_val / 4), 0, 255); break;
case CV_DEST_MAP_Y: active_map_y = constrain(active_map_y + (cv2_val / 4), 0, 255); break;
default: break;
}
// Evaluate hits for Kick, Snare, HiHats
for (int inst = 0; inst < 3; inst++) {
uint8_t threshold = GetThreshold(inst, current_step, mod_map_x, map_y);
int active_density = inst_density[inst];
uint8_t threshold = GetThreshold(inst, current_step, active_map_x, active_map_y);
int active_density = active_dens[inst];
// Inject chaos
if (active_chaos > 0) {
@ -187,14 +247,13 @@ void ProcessSequencerTick(uint32_t tick) {
}
void OnPlayPress() {
if (is_playing) {
if (!gravity.clock.IsPaused()) {
gravity.clock.Stop();
for (int i = 0; i < 6; i++)
gravity.outputs[i].Low();
} else {
gravity.clock.Start();
}
is_playing = !is_playing;
needs_redraw = true;
}
@ -203,37 +262,103 @@ void OnEncoderPress() {
needs_redraw = true;
}
void OnEncoderPressRotate(int val) {
// Toggle between Pattern and Global pages
if (val > 0) {
current_page = PAGE_GLOBAL;
} else {
current_page = PAGE_PATTERN;
}
// Reset editing state when swapping pages
editing_param = false;
needs_redraw = true;
}
// Map CV Destination enum string labels
const char* GetCvDestName(CvDest dest) {
switch(dest) {
case CV_DEST_NONE: return "NONE";
case CV_DEST_KICK_DENS: return "KICK DENS";
case CV_DEST_SNARE_DENS: return "SNAR DENS";
case CV_DEST_HHAT_DENS: return "HHAT DENS";
case CV_DEST_CHAOS: return "CHAOS";
case CV_DEST_MAP_X: return "MAP X";
case CV_DEST_MAP_Y: return "MAP Y";
default: return "UNKNOWN";
}
}
void OnEncoderRotate(int val) {
if (!editing_param) {
// Navigate menu (clamp to edges, do not wrap)
int next_param = (int)current_param + val;
next_param = constrain(next_param, 0, PARAM_LAST - 1);
current_param = (SelectedParam)next_param;
if (current_page == PAGE_PATTERN) {
int next_param = (int)current_param + val;
next_param = constrain(next_param, 0, PARAM_LAST - 1);
current_param = (SelectedParam)next_param;
} else {
int next_param = (int)current_global_param + val;
next_param = constrain(next_param, 0, PARAM_GLOBAL_LAST - 1);
current_global_param = (GlobalParam)next_param;
}
} else {
// Edit parameter
int amt = val * 8; // Adjust by 8 values at a time for speed mapping
if (current_page == PAGE_PATTERN) {
int amt = val * 8; // Adjust by 8 values at a time for speed mapping
switch (current_param) {
case PARAM_KICK_DENS:
inst_density[0] = constrain(inst_density[0] + amt, 0, 255);
break;
case PARAM_SNARE_DENS:
inst_density[1] = constrain(inst_density[1] + amt, 0, 255);
break;
case PARAM_HIHAT_DENS:
inst_density[2] = constrain(inst_density[2] + amt, 0, 255);
break;
case PARAM_CHAOS:
chaos_amount = constrain(chaos_amount + amt, 0, 255);
break;
case PARAM_MAP_X:
map_x = constrain(map_x + amt, 0, 255);
break;
case PARAM_MAP_Y:
map_y = constrain(map_y + amt, 0, 255);
break;
default:
break;
switch (current_param) {
case PARAM_KICK_DENS:
inst_density[0] = constrain(inst_density[0] + amt, 0, 255);
break;
case PARAM_SNARE_DENS:
inst_density[1] = constrain(inst_density[1] + amt, 0, 255);
break;
case PARAM_HIHAT_DENS:
inst_density[2] = constrain(inst_density[2] + amt, 0, 255);
break;
case PARAM_CHAOS:
chaos_amount = constrain(chaos_amount + amt, 0, 255);
break;
case PARAM_MAP_X:
map_x = constrain(map_x + amt, 0, 255);
break;
case PARAM_MAP_Y:
map_y = constrain(map_y + amt, 0, 255);
break;
default:
break;
}
} else {
// Edit Global Parameter
switch (current_global_param) {
case PARAM_GLOBAL_CLK_SRC: {
int src = (int)selected_source + val;
src = constrain(src, 0, Clock::SOURCE_LAST - 1);
selected_source = (Clock::Source)src;
gravity.clock.SetSource(selected_source);
break;
}
case PARAM_GLOBAL_BPM: {
if (!gravity.clock.ExternalSource()) {
gravity.clock.SetTempo(gravity.clock.Tempo() + val);
}
break;
}
case PARAM_GLOBAL_CV1_DEST: {
int dest = (int)cv1_dest + val;
dest = constrain(dest, 0, CV_DEST_LAST - 1);
cv1_dest = (CvDest)dest;
break;
}
case PARAM_GLOBAL_CV2_DEST: {
int dest = (int)cv2_dest + val;
dest = constrain(dest, 0, CV_DEST_LAST - 1);
cv2_dest = (CvDest)dest;
break;
}
default:
break;
}
}
eeprom_needs_save = true;
@ -242,92 +367,225 @@ void OnEncoderRotate(int val) {
needs_redraw = true;
}
void DrawBarGraph(int y, const char *label, int value, bool is_selected) {
// Reset draw color to default foreground
gravity.display.setDrawColor(1);
gravity.display.setCursor(0, y);
#include "display_assets.h"
if (is_selected) {
gravity.display.print(">");
if (editing_param) {
// Draw solid white box behind the label
gravity.display.drawBox(6, y - 8, 26, 10);
// Switch to black text to 'cut out' the label from the box
gravity.display.setDrawColor(0);
}
} else {
gravity.display.print(" ");
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;
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 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);
}
void drawMenuItems(String menu_items[], int menu_size, int current_item) {
gravity.display.setFont(TEXT_FONT);
int selectedBoxY = 0;
if (menu_size >= VISIBLE_MENU_ITEMS && current_item == menu_size - 1) {
selectedBoxY = MENU_ITEM_HEIGHT * min(2, current_item);
} else if (current_item > 0) {
selectedBoxY = MENU_ITEM_HEIGHT;
}
gravity.display.setCursor(6, y);
gravity.display.print(label);
int boxX = MENU_BOX_WIDTH + 1;
int boxY = selectedBoxY + 2;
int boxWidth = MENU_BOX_WIDTH - 1;
int boxHeight = MENU_ITEM_HEIGHT + 1;
// Restore draw color to white for the bar and text
gravity.display.setDrawColor(1);
if (editing_param) {
gravity.display.drawBox(boxX, boxY, boxWidth, boxHeight);
drawMainSelection();
} else {
gravity.display.drawFrame(boxX, boxY, boxWidth, boxHeight);
}
// Draw Bar
int barLen = map(value, 0, 255, 0, 60);
gravity.display.drawFrame(34, y - 8, 60, 8);
gravity.display.drawBox(34, y - 8, barLen, 8);
gravity.display.setDrawColor(2);
int start_index = 0;
if (menu_size >= VISIBLE_MENU_ITEMS && current_item == menu_size - 1) {
start_index = menu_size - VISIBLE_MENU_ITEMS;
} else if (current_item > 0) {
start_index = current_item - 1;
}
// Draw value percentage
gravity.display.setCursor(98, y);
int pct = map(value, 0, 255, 0, 100);
gravity.display.print(pct);
gravity.display.print("%");
for (int i = 0; i < min(menu_size, (int)VISIBLE_MENU_ITEMS); ++i) {
int idx = start_index + i;
drawRightAlignedText(menu_items[idx].c_str(),
MENU_ITEM_HEIGHT * (i + 1) - 1);
}
}
void DisplayMainArea() {
gravity.display.setFontMode(1);
gravity.display.setDrawColor(2);
String mainText;
String subText;
if (current_page == PAGE_PATTERN) {
switch (current_param) {
case PARAM_KICK_DENS:
mainText = String(map(inst_density[0], 0, 255, 0, 100)) + "%";
subText = "KICK DENS";
break;
case PARAM_SNARE_DENS:
mainText = String(map(inst_density[1], 0, 255, 0, 100)) + "%";
subText = "SNAR DENS";
break;
case PARAM_HIHAT_DENS:
mainText = String(map(inst_density[2], 0, 255, 0, 100)) + "%";
subText = "HHAT DENS";
break;
case PARAM_CHAOS:
mainText = String(map(chaos_amount, 0, 255, 0, 100)) + "%";
subText = "CHAOS";
break;
case PARAM_MAP_X:
mainText = String(map_x);
subText = "MAP X";
break;
case PARAM_MAP_Y:
mainText = String(map_y);
subText = "MAP Y";
break;
default:
break;
}
drawCenteredText(mainText.c_str(), MAIN_TEXT_Y, LARGE_FONT);
drawCenteredText(subText.c_str(), SUB_TEXT_Y, TEXT_FONT);
String menu_items[PARAM_LAST] = {
"KICK", "SNARE", "HHAT", "CHAOS", "MAP X", "MAP Y"};
drawMenuItems(menu_items, PARAM_LAST, (int)current_param);
} else if (current_page == PAGE_GLOBAL) {
switch (current_global_param) {
case PARAM_GLOBAL_CLK_SRC:
switch (selected_source) {
case Clock::SOURCE_INTERNAL:
mainText = F("INT");
subText = F("CLOCK");
break;
case Clock::SOURCE_EXTERNAL_PPQN_24:
mainText = F("EXT");
subText = F("24 PPQN");
break;
case Clock::SOURCE_EXTERNAL_PPQN_4:
mainText = F("EXT");
subText = F("4 PPQN");
break;
case Clock::SOURCE_EXTERNAL_PPQN_2:
mainText = F("EXT");
subText = F("2 PPQN");
break;
case Clock::SOURCE_EXTERNAL_PPQN_1:
mainText = F("EXT");
subText = F("1 PPQN");
break;
case Clock::SOURCE_EXTERNAL_MIDI:
mainText = F("EXT");
subText = F("MIDI");
break;
}
break;
case PARAM_GLOBAL_BPM:
if (gravity.clock.ExternalSource()) {
mainText = F("EXT");
} else {
mainText = String(gravity.clock.Tempo());
}
subText = F("BPM");
break;
case PARAM_GLOBAL_CV1_DEST:
mainText = F("CV1");
subText = GetCvDestName(cv1_dest);
break;
case PARAM_GLOBAL_CV2_DEST:
mainText = F("CV2");
subText = GetCvDestName(cv2_dest);
break;
default:
break;
}
drawCenteredText(mainText.c_str(), MAIN_TEXT_Y, LARGE_FONT);
drawCenteredText(subText.c_str(), SUB_TEXT_Y, TEXT_FONT);
String menu_items[PARAM_GLOBAL_LAST] = {
"SOURCE", "TEMPO", "CV1 DEST", "CV2 DEST"};
drawMenuItems(menu_items, PARAM_GLOBAL_LAST, (int)current_global_param);
}
}
void DisplayBottomBar() {
int boxY = 50;
int boxHeight = 14;
int boxX_Pattern = 18;
int boxWidth = 55;
int boxX_Global = boxX_Pattern + boxWidth;
gravity.display.setDrawColor(1);
gravity.display.drawHLine(1, boxY, SCREEN_WIDTH - 2);
gravity.display.drawVLine(SCREEN_WIDTH - 1, boxY, boxHeight);
if (current_page == PAGE_PATTERN) {
gravity.display.drawBox(boxX_Pattern, boxY, boxWidth, boxHeight);
gravity.display.drawVLine(boxX_Global, boxY, boxHeight);
} else {
gravity.display.drawVLine(boxX_Pattern, boxY, boxHeight);
gravity.display.drawBox(boxX_Global, boxY, boxWidth, boxHeight);
}
gravity.display.setDrawColor(2);
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);
gravity.display.setFont(TEXT_FONT);
int patW = gravity.display.getStrWidth("PATTERN");
int patX = boxX_Pattern + (boxWidth / 2) - (patW / 2);
gravity.display.drawStr(patX, 64 - 3, "PATTERN");
int gloW = gravity.display.getStrWidth("GLOBAL");
int gloX = boxX_Global + (boxWidth / 2) - (gloW / 2);
gravity.display.drawStr(gloX, 64 - 3, "GLOBAL");
}
void UpdateDisplay() {
gravity.display.setFontMode(1);
gravity.display.setDrawColor(1);
gravity.display.setFont(u8g2_font_5x7_tf);
// Header
gravity.display.setCursor(0, 7);
if (is_playing)
gravity.display.print("[>] PLAY");
else
gravity.display.print("[||] PAUS");
gravity.display.setCursor(55, 7);
gravity.display.print("BPM:");
gravity.display.print(gravity.clock.Tempo());
gravity.display.drawHLine(0, 10, 128);
// Parameters List (Scrollable window of 5 items)
int y_start = 20;
int y_spacing = 9;
// Calculate window start index
int window_start = max(0, min((int)current_param - 2, PARAM_LAST - 5));
for (int i = 0; i < 5; i++) {
int param_idx = window_start + i;
int y_pos = y_start + (y_spacing * i);
bool is_sel = (current_param == param_idx);
switch (param_idx) {
case PARAM_KICK_DENS:
DrawBarGraph(y_pos, "KICK", inst_density[0], is_sel);
break;
case PARAM_SNARE_DENS:
DrawBarGraph(y_pos, "SNAR", inst_density[1], is_sel);
break;
case PARAM_HIHAT_DENS:
DrawBarGraph(y_pos, "HHAT", inst_density[2], is_sel);
break;
case PARAM_CHAOS:
DrawBarGraph(y_pos, "CHAO", chaos_amount, is_sel);
break;
case PARAM_MAP_X:
DrawBarGraph(y_pos, "MAPX", map_x, is_sel);
break;
case PARAM_MAP_Y:
DrawBarGraph(y_pos, "MAPY", map_y, is_sel);
break;
}
}
DisplayMainArea();
DisplayBottomBar();
}
void setup() {
@ -337,6 +595,7 @@ void setup() {
gravity.play_button.AttachPressHandler(OnPlayPress);
gravity.encoder.AttachPressHandler(OnEncoderPress);
gravity.encoder.AttachRotateHandler(OnEncoderRotate);
gravity.encoder.AttachPressRotateHandler(OnEncoderPressRotate);
gravity.clock.AttachIntHandler(ProcessSequencerTick);
// Default to 120 BPM internal