diff --git a/firmware/Rhythm/Rhythm.ino b/firmware/Rhythm/Rhythm.ino new file mode 100644 index 0000000..6c73e9b --- /dev/null +++ b/firmware/Rhythm/Rhythm.ino @@ -0,0 +1,371 @@ +#include "maps.h" +#include +#include +#include + +// Configuration +const int MAX_DENSITY = 255; +const int MAP_RESOLUTION = 255; +const int MAX_CHAOS = 255; + +// EEPROM addrs +const int EEPROM_INIT_ADDR = 0; +const int EEPROM_DENS_K = 1; +const int EEPROM_DENS_S = 3; +const int EEPROM_DENS_H = 5; +const int EEPROM_MAP_X = 7; +const int EEPROM_MAP_Y = 9; +const int EEPROM_CHAOS = 11; +const byte EEPROM_INIT_FLAG = 0xAC; // Update flag to re-init + +// EEPROM Delay Save +const unsigned long SAVE_DELAY_MS = 5000; +bool eeprom_needs_save = false; +unsigned long last_param_change = 0; + +// UI & Navigation +enum SelectedParam { + PARAM_KICK_DENS = 0, + PARAM_SNARE_DENS = 1, + PARAM_HIHAT_DENS = 2, + PARAM_CHAOS = 3, + PARAM_MAP_X = 4, + PARAM_MAP_Y = 5, + PARAM_LAST = 6 +}; + +SelectedParam current_param = PARAM_KICK_DENS; +bool editing_param = false; +bool needs_redraw = true; +unsigned long last_redraw = 0; +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, + 128}; // Default 50% density for kick, snare, hihat +int map_x = 0; // 0 to 255 (0 = House, 127 = Breakbeat, 255 = Hiphop) +int map_y = 127; // 0 to 255 (0 = Sparse, 127 = Standard, 255 = Busy) +int chaos_amount = 0; // 0 to 255 + +volatile int cv1_val = 0; +volatile int cv2_val = 0; + +// LFSR State for Chaos +uint16_t lfsr = 0xACE1; + +// Math Helper: 1D Linear Interpolation between two bytes +uint8_t lerp(uint8_t a, uint8_t b, uint8_t t) { + // t is 0-255. returns a if t=0, b if t=255 + return a + (((b - a) * t) >> 8); +} + +// Math Helper: Get threshold from 2D map via interpolation +uint8_t GetThreshold(int inst, int step, int x_pos, int y_pos) { + // x_pos is 0-255 mapped across 4 nodes (0, 1, 2, 3). Distance is 85 (255 / 3) + // y_pos is 0-255 mapped across 4 nodes (0, 1, 2, 3). Distance is 85 (255 / 3) + + int x_idx = x_pos / 85; + int y_idx = y_pos / 85; + + uint8_t x_frac = (x_pos % 85) * 3; // scale remainder 0-84 up to 0-255 + uint8_t y_frac = (y_pos % 85) * 3; + + // Guard against out of bounds if exactly 255 + if (x_idx >= 3) { + x_idx = 2; + x_frac = 255; + } + if (y_idx >= 3) { + y_idx = 2; + y_frac = 255; + } + + // Read 4 corners from PROGMEM + uint8_t p00 = pgm_read_byte(&PATTERN_MAPS[x_idx][y_idx][inst][step]); + uint8_t p10 = pgm_read_byte(&PATTERN_MAPS[x_idx + 1][y_idx][inst][step]); + uint8_t p01 = pgm_read_byte(&PATTERN_MAPS[x_idx][y_idx + 1][inst][step]); + uint8_t p11 = pgm_read_byte(&PATTERN_MAPS[x_idx + 1][y_idx + 1][inst][step]); + + // Bilinear interpolation + uint8_t lerp_top = lerp(p00, p10, x_frac); + uint8_t lerp_bottom = lerp(p01, p11, x_frac); + return lerp(lerp_top, lerp_bottom, y_frac); +} + +void LoadState() { + if (EEPROM.read(EEPROM_INIT_ADDR) == EEPROM_INIT_FLAG) { + EEPROM.get(EEPROM_DENS_K, inst_density[0]); + EEPROM.get(EEPROM_DENS_S, inst_density[1]); + EEPROM.get(EEPROM_DENS_H, inst_density[2]); + EEPROM.get(EEPROM_MAP_X, map_x); + EEPROM.get(EEPROM_MAP_Y, map_y); + EEPROM.get(EEPROM_CHAOS, chaos_amount); + } +} + +void SaveState() { + EEPROM.update(EEPROM_INIT_ADDR, EEPROM_INIT_FLAG); + EEPROM.put(EEPROM_DENS_K, inst_density[0]); + EEPROM.put(EEPROM_DENS_S, inst_density[1]); + EEPROM.put(EEPROM_DENS_H, inst_density[2]); + EEPROM.put(EEPROM_MAP_X, map_x); + EEPROM.put(EEPROM_MAP_Y, map_y); + EEPROM.put(EEPROM_CHAOS, chaos_amount); +} + +// LFSR random bit generator (returns 0 or 1, fast) +uint8_t GetRandomBit() { + uint8_t bit = ((lfsr >> 0) ^ (lfsr >> 2) ^ (lfsr >> 3) ^ (lfsr >> 5)) & 1; + lfsr = (lfsr >> 1) | (bit << 15); + return bit; +} + +// Get 8-bit pseudo-random number +uint8_t GetRandomByte() { + uint8_t r = 0; + for (int i = 0; i < 8; i++) { + r = (r << 1) | GetRandomBit(); + } + return r; +} + +void ProcessSequencerTick(uint32_t tick) { + // Assuming 96 PPQN clock. We want 16th notes. + // 96 pulses per quarter note / 4 = 24 pulses per 16th note. + const int PULSES_PER_16TH = 24; + + // Pulse logic outputs low halfway through the 16th note (12 pulses) + if (tick % PULSES_PER_16TH == 12) { + for (int i = 0; i < 6; i++) { + gravity.outputs[i].Low(); + } + } + + // 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); + + // 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]; + + // Inject chaos + if (active_chaos > 0) { + // Chaos randomly adds or subtracts from density. + int r = GetRandomByte(); + int chaos_variance = map(active_chaos, 0, 255, 0, 128); + if (GetRandomBit()) { + active_density += map(r, 0, 255, 0, chaos_variance); + } else { + active_density -= map(r, 0, 255, 0, chaos_variance); + } + active_density = constrain(active_density, 0, 255); + } + + // Fire Trigger? + if (active_density > threshold) { + // Output 1-3 + gravity.outputs[inst].High(); + + // Fire Accent Trigger? (If density greatly exceeds threshold) + if (active_density > threshold + 60) { + // Output 4-6 + gravity.outputs[inst + 3].High(); + } + } + } + + current_step = (current_step + 1) % 16; + } +} + +void OnPlayPress() { + if (is_playing) { + 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; +} + +void OnEncoderPress() { + editing_param = !editing_param; + needs_redraw = true; +} + +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; + } else { + // Edit parameter + 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; + } + + eeprom_needs_save = true; + last_param_change = millis(); + } + 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); + + 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(" "); + } + + gravity.display.setCursor(6, y); + gravity.display.print(label); + + // Restore draw color to white for the bar and text + gravity.display.setDrawColor(1); + + // 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); + + // Draw value percentage + gravity.display.setCursor(98, y); + int pct = map(value, 0, 255, 0, 100); + gravity.display.print(pct); + gravity.display.print("%"); +} + +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; + } + } +} + +void setup() { + gravity.Init(); + LoadState(); + + gravity.play_button.AttachPressHandler(OnPlayPress); + gravity.encoder.AttachPressHandler(OnEncoderPress); + gravity.encoder.AttachRotateHandler(OnEncoderRotate); + + gravity.clock.AttachIntHandler(ProcessSequencerTick); + // Default to 120 BPM internal + gravity.clock.SetTempo(120); + gravity.clock.SetSource(Clock::SOURCE_INTERNAL); + + // Speed up I2C for faster OLED refreshing + Wire.setClock(400000); +} + +void loop() { + gravity.Process(); + + // Apply CV modulation + // CV1 modulates Map X, CV2 modulates Chaos + cv1_val = gravity.cv1.Read(); // -512 to 512 + cv2_val = gravity.cv2.Read(); + + if (eeprom_needs_save && (millis() - last_param_change > SAVE_DELAY_MS)) { + SaveState(); + eeprom_needs_save = false; + } + + if (needs_redraw && (millis() - last_redraw > REDRAW_DELAY_MS)) { + needs_redraw = false; + last_redraw = millis(); + gravity.display.firstPage(); + do { + UpdateDisplay(); + } while (gravity.display.nextPage()); + } +} diff --git a/firmware/Rhythm/maps.h b/firmware/Rhythm/maps.h new file mode 100644 index 0000000..f9ead34 --- /dev/null +++ b/firmware/Rhythm/maps.h @@ -0,0 +1,136 @@ +#ifndef MAPS_H +#define MAPS_H + +#include +#include + +// 4x4 Grid of Patterns (X = Genre, Y = Variation) +// X=0: House/Techno (4 on floor) +// X=1: Breakbeat/Drum&Bass (Syncopated) +// X=2: HipHop/Trap (Half-time feel) +// X=3: Dem Bow / Reggaeton (Tresillo rhythm) +// Y=0: Sparse/Basic +// Y=1: Standard +// Y=2: Syncopated/Groove +// Y=3: Busy/Fills + +// 3 Instruments (Kick=0, Snare=1, HiHat=2) +// 16 Steps per pattern. +// Values are "Thresholds" (0-255). A step fires if the channel's Density setting > Threshold. +// A Threshold of 255 means it NEVER fires. 0 means it ALWAYS fires (even at minimum density). + +const uint8_t PATTERN_MAPS[4][4][3][16] PROGMEM = { + // X = 0 (House) + { + // Y = 0 (Sparse) + { + { 20, 255, 255, 255, 20, 255, 255, 255, 20, 255, 255, 255, 20, 255, 255, 255 }, // Kick + { 255, 255, 255, 255, 50, 255, 255, 255, 255, 255, 255, 255, 50, 255, 255, 255 }, // Snare + { 255, 255, 60, 255, 255, 255, 60, 255, 255, 255, 60, 255, 255, 255, 60, 255 } // HiHat (Offbeats) + }, + // Y = 1 (Standard) + { + { 10, 255, 200, 255, 10, 255, 200, 255, 10, 255, 200, 255, 10, 255, 200, 255 }, // Kick + { 255, 255, 255, 255, 30, 255, 255, 200, 255, 255, 255, 255, 30, 255, 255, 200 }, // Snare + { 150, 80, 40, 80, 150, 80, 40, 80, 150, 80, 40, 80, 150, 80, 40, 80 } // HiHat + }, + // Y = 2 (Syncopated Break/Groove) + { + { 10, 255, 180, 255, 255, 180, 150, 255, 10, 255, 180, 200, 255, 180, 150, 255 }, // Kick (pushing beats) + { 255, 200, 255, 200, 20, 200, 255, 150, 255, 200, 255, 200, 20, 200, 255, 150 }, // Snare (ghost notes) + { 60, 150, 20, 150, 80, 150, 20, 150, 60, 150, 20, 150, 80, 150, 20, 150 } // HiHat (dynamic 16ths) + }, + // Y = 3 (Busy / Driving) + { + { 5, 180, 150, 200, 5, 180, 150, 200, 5, 180, 150, 200, 5, 180, 150, 200 }, // Kick + { 255, 200, 200, 255, 20, 200, 200, 180, 255, 200, 200, 255, 20, 200, 200, 180 }, // Snare + { 20, 60, 10, 60, 20, 60, 10, 60, 20, 60, 10, 60, 20, 60, 10, 60 } // HiHat (accented 16ths) + } + }, + + // X = 1 (Breakbeat) + { + // Y = 0 (Sparse) + { + { 20, 255, 255, 255, 255, 255, 100, 255, 255, 255, 255, 255, 255, 255, 255, 255 }, // Kick (1, 3& half) + { 255, 255, 255, 255, 50, 255, 255, 255, 255, 255, 255, 255, 50, 255, 255, 255 }, // Snare (2, 4) + { 60, 255, 150, 255, 60, 255, 150, 255, 60, 255, 150, 255, 60, 255, 150, 255 } // HiHat (8ths) + }, + // Y = 1 (Standard) + { + { 10, 255, 150, 255, 255, 180, 80, 200, 255, 150, 255, 200, 255, 255, 150, 255 }, // Kick + { 255, 255, 200, 255, 30, 255, 255, 150, 255, 255, 180, 255, 30, 255, 200, 180 }, // Snare (syncopated ghosts) + { 40, 120, 80, 120, 40, 120, 80, 120, 40, 120, 80, 120, 40, 120, 80, 120 } // HiHat + }, + // Y = 2 (Jungle / Fast Syncopation) + { + { 10, 200, 255, 150, 255, 200, 80, 255, 10, 200, 255, 150, 255, 200, 80, 200 }, // Kick + { 255, 150, 200, 180, 20, 200, 150, 100, 255, 150, 200, 180, 20, 200, 150, 100 }, // Snare (heavy ghosts & rolls) + { 50, 150, 150, 100, 50, 150, 150, 100, 50, 150, 150, 100, 50, 150, 150, 100 } // HiHat (dynamic 16ths) + }, + // Y = 3 (Busy Breakcore) + { + { 5, 150, 120, 150, 180, 100, 50, 120, 150, 80, 150, 100, 200, 80, 100, 150 }, // Kick + { 200, 180, 150, 200, 20, 150, 180, 100, 180, 120, 100, 150, 20, 100, 120, 80 }, // Snare + { 20, 50, 40, 50, 20, 50, 40, 50, 20, 50, 40, 50, 20, 50, 40, 50 } // HiHat (16ths + dynamic) + } + }, + + // X = 2 (HipHop) + { + // Y = 0 (Sparse) + { + { 20, 255, 255, 255, 255, 255, 255, 255, 100, 255, 255, 255, 255, 255, 255, 255 }, // Kick (1, 3) + { 255, 255, 255, 255, 60, 255, 255, 255, 255, 255, 255, 255, 60, 255, 255, 255 }, // Snare (2, 4) + { 80, 255, 255, 255, 80, 255, 255, 255, 80, 255, 255, 255, 80, 255, 255, 255 } // HiHat (Quarters) + }, + // Y = 1 (Standard) + { + { 10, 255, 255, 150, 255, 255, 255, 200, 80, 255, 150, 255, 255, 255, 255, 200 }, // Kick + { 255, 255, 255, 255, 40, 255, 255, 255, 255, 255, 255, 255, 40, 255, 200, 255 }, // Snare + { 50, 150, 50, 150, 50, 150, 50, 150, 50, 150, 50, 150, 50, 150, 50, 150 } // HiHat (8ths) + }, + // Y = 2 (Boom Bap / Swing) + { + { 10, 255, 150, 200, 255, 255, 150, 200, 50, 255, 100, 255, 255, 200, 150, 255 }, // Kick (Swung 16ths) + { 255, 255, 200, 255, 30, 255, 200, 150, 255, 255, 200, 255, 30, 200, 255, 150 }, // Snare (Ghost notes) + { 30, 200, 80, 150, 30, 200, 80, 150, 30, 200, 80, 150, 30, 200, 80, 150 } // HiHat (Heavy swing feel) + }, + // Y = 3 (Busy / Trap) + { + { 5, 200, 150, 80, 200, 180, 150, 120, 40, 150, 80, 200, 150, 180, 120, 100 }, // Kick + { 255, 255, 200, 255, 20, 200, 255, 150, 255, 255, 180, 255, 20, 150, 180, 100 }, // Snare (with trap rolls) + { 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20 } // HiHat (rapid 16ths) + } + }, + + // X = 3 (Dem Bow / Reggaeton - Tresillo Rhythm) + { + // Y = 0 (Sparse) + { + { 20, 255, 255, 255, 20, 255, 255, 255, 20, 255, 255, 255, 20, 255, 255, 255 }, // Kick (4 on floor) + { 255, 255, 255, 60, 255, 255, 60, 255, 255, 255, 255, 60, 255, 255, 60, 255 }, // Snare (Tresillo off-beats) + { 255, 255, 150, 255, 255, 255, 150, 255, 255, 255, 150, 255, 255, 255, 150, 255 } // HiHat (basic offbeats) + }, + // Y = 1 (Standard) + { + { 10, 255, 200, 255, 10, 255, 200, 255, 10, 255, 200, 255, 10, 255, 200, 255 }, // Kick + { 255, 255, 255, 30, 255, 255, 30, 200, 255, 255, 255, 30, 255, 255, 30, 200 }, // Snare (Strong Tresillo + syncopation) + { 100, 200, 50, 200, 100, 200, 50, 200, 100, 200, 50, 200, 100, 200, 50, 200 } // HiHat (8th note swing) + }, + // Y = 2 (Syncopated Tresillo) + { + { 10, 255, 150, 200, 10, 255, 150, 200, 10, 255, 150, 200, 10, 255, 150, 200 }, // Kick (Driving but syncopated) + { 255, 200, 255, 20, 200, 255, 20, 150, 255, 200, 255, 20, 200, 255, 20, 150 }, // Snare (Pushing the backbeat) + { 50, 150, 100, 150, 50, 150, 100, 150, 50, 150, 100, 150, 50, 150, 100, 150 } // HiHat (Syncopated ride) + }, + // Y = 3 (Busy / Rolls) + { + { 5, 200, 150, 120, 5, 200, 150, 120, 5, 200, 150, 120, 5, 200, 150, 120 }, // Kick (driving) + { 255, 180, 200, 20, 180, 200, 20, 120, 255, 180, 200, 20, 180, 200, 20, 80 }, // Snare (Dem Bow rolls) + { 30, 80, 40, 80, 30, 80, 40, 80, 30, 80, 40, 80, 30, 80, 40, 80 } // HiHat (16ths riding on the tresillo) + } + } +}; + +#endif