#pragma once #include "HSApplication.h" #include "OC_apps.h" #include "OC_DAC.h" #include "OC_ADC.h" #include "OC_io.h" #include "OC_strings.h" #include "util/util_stream_buffer.h" #ifndef ORN8_DEFER_INVERT #define ORN8_DEFER_INVERT // Inverts the cursor row AFTER its text is drawn (end of scope), // so the selected value shows as dark text on a light bar. struct OrnDeferInvert { bool on; int x, y, w, h; OrnDeferInvert(bool o, int x_, int y_, int w_, int h_) : on(o), x(x_), y(y_), w(w_), h(h_) {} ~OrnDeferInvert() { if (on) graphics.invertRect(x, y, w, h); } }; #endif #ifdef ARDUINO_TEENSY41 // ============================================================================ // HooverController - Full-Screen 8-Channel CV Synth Controller for Phazerville // Specially engineered for Römholt Modular T4.1 / O.R.N.8 Hardware // Controlling Doepfer A-110-1/2, Behringer Four Play, Doepfer SEM, Eloquencer // ============================================================================ namespace hoover { // Fixed-point / ISR constants static constexpr uint32_t ISR_RATE = 16666; static constexpr int PITCH_PER_OCTAVE = 1536; // 12 << 7 static constexpr int PITCH_PER_SEMITONE = 128; // 1 << 7 // Preset counts static constexpr uint8_t NUM_FACTORY_PRESETS = 8; static constexpr uint8_t NUM_USER_PRESETS = 4; static constexpr uint8_t TOTAL_PRESETS = NUM_FACTORY_PRESETS + NUM_USER_PRESETS; enum PitchMode : uint8_t { PITCH_MODE_FALL = 0, PITCH_MODE_RISE_FALL = 1 }; enum CurveShape : uint8_t { CURVE_LINEAR = 0, CURVE_EXPONENTIAL = 1 }; enum RetrigMode : uint8_t { RETRIG_SMOOTH = 0, RETRIG_HARD = 1 }; enum SubOctave : int8_t { SUB_OCT_OFF = 0, SUB_OCT_MINUS_1 = -1, SUB_OCT_MINUS_2 = -2 }; enum PwmLinkMode : uint8_t { PWM_LINKED = 0, PWM_DETUNED = 1, PWM_INDEPENDENT = 2 }; enum PwmWaveform : uint8_t { PWM_WAVE_TRI = 0, PWM_WAVE_SINE = 1 }; enum AmpMode : uint8_t { AMP_MODE_STAB = 0, AMP_MODE_HOLD = 1 }; enum FltMode : uint8_t { FLT_MODE_AD = 0, FLT_MODE_ADSR = 1 }; enum AuxMode : uint8_t { AUX_MOD_LFO3 = 0, AUX_ACCENT_EG = 1, AUX_VELOCITY_CV = 2, AUX_MACRO_CV = 3, AUX_PITCH_COPY = 4 }; enum MacroDest : uint8_t { MACRO_NONE = 0, MACRO_FLT_CUTOFF = 1, MACRO_PITCH_AMT = 2, MACRO_PWM_DEPTH = 3, MACRO_DETUNE = 4, MACRO_DECAY_TIME = 5 }; enum VelDest : uint8_t { VEL_NONE = 0, VEL_AMP_LEVEL = 1, VEL_FLT_AMOUNT = 2, VEL_PITCH_AMOUNT = 3 }; // Preset Structure (~56 bytes) struct Preset { char name[12]; // Pitch EG int8_t pitch_amount_st; // -36 .. +36 st uint16_t pitch_rise_ms; // 1 .. 2000 ms uint16_t pitch_fall_ms; // 10 .. 5000 ms uint8_t pitch_mode; // PitchMode uint8_t pitch_curve; // CurveShape uint8_t pitch_retrig; // RetrigMode // Dedicated Pitch Chorus / Vibrato LFO uint16_t pitch_lfo_rate; // 10 .. 800 (0.10 .. 8.00 Hz) uint8_t pitch_lfo_depth; // 0 .. 60 cents uint8_t pitch_lfo_spread; // 0: In-phase, 1: Antiphase (swirling chorus) int8_t sub_octave; // SubOctave uint8_t sub_env_share; // 0 .. 100 % uint16_t glide_ms; // 0 .. 500 ms uint8_t quantize; // 0: Off, 1: Semitone // VCO2 int8_t vco2_interval; // -24 .. +24 st int8_t vco2_detune; // -100 .. +100 cents // PWM LFOs uint8_t pwm_mode; // PwmLinkMode uint8_t pwm_wave; // PwmWaveform uint16_t pwm_rate_a; // 5 .. 2000 (0.05 .. 20.00 Hz) uint8_t pwm_depth_a; // 0 .. 100 % int16_t pwm_offset_a; // -2500 .. +2500 mV uint16_t pwm_param_b; // phase 0..350 / ratio 80..130 / rate 5..2000 uint8_t pwm_depth_b; // 0 .. 100 % int16_t pwm_offset_b; // -2500 .. +2500 mV uint8_t pwm_sync; // 0: Off, 1: 0 deg, 2: 90 deg, 3: 180 deg, 4: 270 deg // Amp EG uint8_t amp_mode; // AmpMode uint16_t amp_attack_ms; // 1 .. 2000 ms uint16_t amp_decay_ms; // 10 .. 5000 ms uint8_t amp_sustain_pct; // 0 .. 100 % uint16_t amp_release_ms; // 10 .. 5000 ms uint8_t amp_peak_v; // 3 .. 8 V uint8_t amp_curve; // CurveShape // Filter EG uint8_t flt_mode; // FltMode uint16_t flt_attack_ms; // 1 .. 2000 ms uint16_t flt_decay_ms; // 10 .. 5000 ms uint8_t flt_sustain_pct; // 0 .. 100 % uint16_t flt_release_ms; // 10 .. 5000 ms int8_t flt_amount_pct; // -100 .. +100 % uint8_t flt_base_v; // 0 .. 5 V uint8_t flt_curve; // CurveShape // Aux & Mod uint8_t aux_mode; // AuxMode uint16_t aux_param; // rate or decay time uint8_t macro_dest; // MacroDest int8_t macro_amount; // -100 .. +100 % uint8_t vel_dest; // VelDest }; // Fast quarter-sine table (65 entries for 0 .. 90 deg) static const int16_t SINE_LUT[65] = { 0, 804, 1608, 2410, 3212, 4011, 4808, 5602, 6393, 7179, 7962, 8739, 9512, 10278, 11039, 11793, 12539, 13279, 14010, 14732, 15446, 16151, 16846, 17530, 18204, 18868, 19519, 20159, 20787, 21403, 22005, 22594, 23170, 23731, 24279, 24811, 25329, 25832, 26319, 26790, 27245, 27683, 28105, 28510, 28898, 29268, 29621, 29956, 30273, 30571, 30852, 31113, 31356, 31580, 31785, 31971, 32137, 32285, 32412, 32521, 32609, 32678, 32728, 32757, 32767 }; static inline int32_t fast_sine(uint32_t phase) { uint32_t p = phase >> 24; // 0 .. 255 uint8_t quadrant = p >> 6; uint8_t idx = p & 0x3F; int32_t val = 0; switch (quadrant) { case 0: val = SINE_LUT[idx]; break; case 1: val = SINE_LUT[64 - idx]; break; case 2: val = -SINE_LUT[idx]; break; case 3: val = -SINE_LUT[64 - idx]; break; } return val; } static inline int32_t fast_triangle(uint32_t phase) { if (phase < 0x80000000) { return (int32_t)((phase >> 15) - 32768); } else { return (int32_t)(32767 - ((phase - 0x80000000) >> 15)); } } } // namespace hoover OC_APP_CLASS(AppHoover, TWOCCS("HV"), "Hoover", "Hoover Controller"), public HSApplication { public: static constexpr size_t STORAGE_SIZE = 320; OC_APP_INTERFACE_DECLARE(AppHoover, STORAGE_SIZE); void Start() final; void Controller() final; void View() const final; void Resume() final { isEditing = false; } private: hoover::Preset p; hoover::Preset factory_presets[hoover::NUM_FACTORY_PRESETS]; hoover::Preset user_presets[hoover::NUM_USER_PRESETS]; uint8_t active_preset_idx = 0; uint8_t selected_load_idx = 0; uint8_t target_user_slot = 0; enum EgStage : uint8_t { EG_IDLE = 0, EG_ATTACK = 1, EG_DECAY = 2, EG_SUSTAIN = 3, EG_RELEASE = 4 }; // Pitch EG EgStage pitch_stage = EG_IDLE; uint32_t pitch_tick = 0; uint32_t pitch_target_ticks = 0; int32_t pitch_start_val = 0; int32_t pitch_target_val = 0; int32_t pitch_curr_val = 0; // Amp EG EgStage amp_stage = EG_IDLE; uint32_t amp_tick = 0; uint32_t amp_target_ticks = 0; uint32_t amp_start_level = 0; uint32_t amp_target_level = 0; uint32_t amp_curr_level = 0; // Filter EG EgStage flt_stage = EG_IDLE; uint32_t flt_tick = 0; uint32_t flt_target_ticks = 0; uint32_t flt_start_level = 0; uint32_t flt_target_level = 0; uint32_t flt_curr_level = 0; // Aux EG EgStage aux_stage = EG_IDLE; uint32_t aux_tick = 0; uint32_t aux_target_ticks = 0; uint32_t aux_curr_level = 0; // LFO Accumulators uint32_t lfo_a_phase = 0; uint32_t lfo_b_phase = 0; uint32_t lfo_aux_phase = 0; uint32_t lfo_pitch_phase = 0; // Clock sync (TR2) uint32_t last_tr2_tick = 0; uint32_t tr2_period_ticks = 0; bool tr2_clock_valid = false; // Glide & CV State int32_t slew_pitch = 0; bool first_run = true; bool last_gate = false; bool manual_gate = false; // Visualizer / Telemetry mutable int out_meters[8] = {0}; mutable bool output_clamped = false; mutable int last_detected_note = 0; mutable char status_msg[16] = {0}; mutable uint32_t status_msg_timer = 0; // UI Navigation enum UiPage : uint8_t { PAGE_MAIN = 0, PAGE_PITCH = 1, PAGE_PWM = 2, PAGE_AMP = 3, PAGE_FILTER = 4, PAGE_ROUTING = 5, PAGE_PRESETS = 6, PAGE_COUNT = 7 }; uint8_t current_page = PAGE_MAIN; int8_t cursor = 0; bool isEditing = false; bool fastEdit = false; void InitFactoryPresets(); void LoadPreset(uint8_t idx); void SaveUserPreset(uint8_t user_idx); void RandomizePatch(); void TriggerEnvelopes(); void NoteOff(); void DrawHeader() const; void DrawMainPage() const; void DrawPitchPage() const; void DrawPwmPage() const; void DrawAmpPage() const; void DrawFltPage() const; void DrawRoutingPage() const; void DrawPresetsPage() const; }; // ============================================================================ // Implementation // ============================================================================ FLASHMEM void AppHoover::InitFactoryPresets() { // Preset 0: What The...? (Archetypal 1991 Alpha Juno Hoover Anthem) snprintf(factory_presets[0].name, 12, "%s", "What The..?"); factory_presets[0].pitch_amount_st = 14; // +14 st classic pitch bend dive factory_presets[0].pitch_rise_ms = 4; factory_presets[0].pitch_fall_ms = 720; factory_presets[0].pitch_mode = hoover::PITCH_MODE_FALL; factory_presets[0].pitch_curve = hoover::CURVE_EXPONENTIAL; factory_presets[0].pitch_retrig = hoover::RETRIG_HARD; factory_presets[0].pitch_lfo_rate = 165; // 1.65 Hz chorus rate factory_presets[0].pitch_lfo_depth = 26; // 26 cents vibrato depth factory_presets[0].pitch_lfo_spread = 1; // Antiphase dual-oscillator pitch chorus factory_presets[0].sub_octave = hoover::SUB_OCT_MINUS_1; factory_presets[0].sub_env_share = 35; factory_presets[0].glide_ms = 0; factory_presets[0].quantize = 0; factory_presets[0].vco2_interval = 0; factory_presets[0].vco2_detune = 14; // +14 cents factory_presets[0].pwm_mode = hoover::PWM_LINKED; factory_presets[0].pwm_wave = hoover::PWM_WAVE_TRI; factory_presets[0].pwm_rate_a = 140; // 1.40 Hz factory_presets[0].pwm_depth_a = 50; // 50% (+/-1.25V) factory_presets[0].pwm_offset_a = 0; factory_presets[0].pwm_param_b = 180; // 180 deg anti-phase width factory_presets[0].pwm_depth_b = 50; factory_presets[0].pwm_offset_b = 0; factory_presets[0].pwm_sync = 3; // 180 deg phase sync factory_presets[0].amp_mode = hoover::AMP_MODE_STAB; factory_presets[0].amp_attack_ms = 2; factory_presets[0].amp_decay_ms = 750; factory_presets[0].amp_sustain_pct = 0; factory_presets[0].amp_release_ms = 60; factory_presets[0].amp_peak_v = 5; factory_presets[0].amp_curve = hoover::CURVE_EXPONENTIAL; factory_presets[0].flt_mode = hoover::FLT_MODE_AD; factory_presets[0].flt_attack_ms = 2; factory_presets[0].flt_decay_ms = 680; factory_presets[0].flt_sustain_pct = 0; factory_presets[0].flt_release_ms = 60; factory_presets[0].flt_amount_pct = 80; factory_presets[0].flt_base_v = 2; factory_presets[0].flt_curve = hoover::CURVE_EXPONENTIAL; factory_presets[0].aux_mode = hoover::AUX_MOD_LFO3; factory_presets[0].aux_param = 35; factory_presets[0].macro_dest = hoover::MACRO_FLT_CUTOFF; factory_presets[0].macro_amount = 50; factory_presets[0].vel_dest = hoover::VEL_AMP_LEVEL; // Preset 1: Mentasm Dive (Joey Beltram / Second Trip 2-Octave Plunge) snprintf(factory_presets[1].name, 12, "%s", "MentasmDive"); factory_presets[1].pitch_amount_st = 24; // Massive 2-octave dive factory_presets[1].pitch_rise_ms = 2; factory_presets[1].pitch_fall_ms = 480; factory_presets[1].pitch_mode = hoover::PITCH_MODE_FALL; factory_presets[1].pitch_curve = hoover::CURVE_EXPONENTIAL; factory_presets[1].pitch_retrig = hoover::RETRIG_HARD; factory_presets[1].pitch_lfo_rate = 220; // 2.20 Hz factory_presets[1].pitch_lfo_depth = 32; // 32 cents factory_presets[1].pitch_lfo_spread = 1; factory_presets[1].sub_octave = hoover::SUB_OCT_OFF; factory_presets[1].sub_env_share = 0; factory_presets[1].glide_ms = 0; factory_presets[1].quantize = 0; factory_presets[1].vco2_interval = 7; // Fifth interval (+7 st) factory_presets[1].vco2_detune = 18; factory_presets[1].pwm_mode = hoover::PWM_LINKED; factory_presets[1].pwm_wave = hoover::PWM_WAVE_TRI; factory_presets[1].pwm_rate_a = 280; // 2.80 Hz factory_presets[1].pwm_depth_a = 55; factory_presets[1].pwm_offset_a = 0; factory_presets[1].pwm_param_b = 180; factory_presets[1].pwm_depth_b = 55; factory_presets[1].pwm_offset_b = 0; factory_presets[1].pwm_sync = 1; factory_presets[1].amp_mode = hoover::AMP_MODE_STAB; factory_presets[1].amp_attack_ms = 1; factory_presets[1].amp_decay_ms = 500; factory_presets[1].amp_sustain_pct = 0; factory_presets[1].amp_release_ms = 40; factory_presets[1].amp_peak_v = 5; factory_presets[1].amp_curve = hoover::CURVE_EXPONENTIAL; factory_presets[1].flt_mode = hoover::FLT_MODE_AD; factory_presets[1].flt_attack_ms = 1; factory_presets[1].flt_decay_ms = 440; factory_presets[1].flt_sustain_pct = 0; factory_presets[1].flt_release_ms = 40; factory_presets[1].flt_amount_pct = 90; factory_presets[1].flt_base_v = 1; factory_presets[1].flt_curve = hoover::CURVE_EXPONENTIAL; factory_presets[1].aux_mode = hoover::AUX_MOD_LFO3; factory_presets[1].aux_param = 20; factory_presets[1].macro_dest = hoover::MACRO_PITCH_AMT; factory_presets[1].macro_amount = 50; factory_presets[1].vel_dest = hoover::VEL_AMP_LEVEL; // Preset 2: Rave Stab 92 (Punchy UK Hardcore / Breakbeat Stab) snprintf(factory_presets[2].name, 12, "%s", "RaveStab 92"); factory_presets[2].pitch_amount_st = 12; // Octave drop factory_presets[2].pitch_rise_ms = 1; factory_presets[2].pitch_fall_ms = 260; factory_presets[2].pitch_mode = hoover::PITCH_MODE_FALL; factory_presets[2].pitch_curve = hoover::CURVE_EXPONENTIAL; factory_presets[2].pitch_retrig = hoover::RETRIG_HARD; factory_presets[2].pitch_lfo_rate = 180; factory_presets[2].pitch_lfo_depth = 18; factory_presets[2].pitch_lfo_spread = 1; factory_presets[2].sub_octave = hoover::SUB_OCT_MINUS_1; factory_presets[2].sub_env_share = 0; factory_presets[2].glide_ms = 0; factory_presets[2].quantize = 0; factory_presets[2].vco2_interval = 0; factory_presets[2].vco2_detune = 16; factory_presets[2].pwm_mode = hoover::PWM_INDEPENDENT; factory_presets[2].pwm_wave = hoover::PWM_WAVE_TRI; factory_presets[2].pwm_rate_a = 220; // 2.20 Hz factory_presets[2].pwm_depth_a = 48; factory_presets[2].pwm_offset_a = 0; factory_presets[2].pwm_param_b = 270; // 2.70 Hz independent factory_presets[2].pwm_depth_b = 48; factory_presets[2].pwm_offset_b = 0; factory_presets[2].pwm_sync = 0; factory_presets[2].amp_mode = hoover::AMP_MODE_STAB; factory_presets[2].amp_attack_ms = 1; factory_presets[2].amp_decay_ms = 280; factory_presets[2].amp_sustain_pct = 0; factory_presets[2].amp_release_ms = 40; factory_presets[2].amp_peak_v = 5; factory_presets[2].amp_curve = hoover::CURVE_EXPONENTIAL; factory_presets[2].flt_mode = hoover::FLT_MODE_AD; factory_presets[2].flt_attack_ms = 1; factory_presets[2].flt_decay_ms = 240; factory_presets[2].flt_sustain_pct = 0; factory_presets[2].flt_release_ms = 40; factory_presets[2].flt_amount_pct = 85; factory_presets[2].flt_base_v = 1; factory_presets[2].flt_curve = hoover::CURVE_EXPONENTIAL; factory_presets[2].aux_mode = hoover::AUX_MOD_LFO3; factory_presets[2].aux_param = 40; factory_presets[2].macro_dest = hoover::MACRO_FLT_CUTOFF; factory_presets[2].macro_amount = 50; factory_presets[2].vel_dest = hoover::VEL_FLT_AMOUNT; // Preset 3: Dominator (Human Resource / Dutch Rising Pitch Scoop) snprintf(factory_presets[3].name, 12, "%s", "Dominator"); factory_presets[3].pitch_amount_st = -12; // Octave upward scoop factory_presets[3].pitch_rise_ms = 6; factory_presets[3].pitch_fall_ms = 580; factory_presets[3].pitch_mode = hoover::PITCH_MODE_FALL; factory_presets[3].pitch_curve = hoover::CURVE_EXPONENTIAL; factory_presets[3].pitch_retrig = hoover::RETRIG_HARD; factory_presets[3].pitch_lfo_rate = 140; factory_presets[3].pitch_lfo_depth = 28; factory_presets[3].pitch_lfo_spread = 1; factory_presets[3].sub_octave = hoover::SUB_OCT_OFF; factory_presets[3].sub_env_share = 0; factory_presets[3].glide_ms = 0; factory_presets[3].quantize = 0; factory_presets[3].vco2_interval = 7; // Fifth interval factory_presets[3].vco2_detune = 22; factory_presets[3].pwm_mode = hoover::PWM_LINKED; factory_presets[3].pwm_wave = hoover::PWM_WAVE_TRI; factory_presets[3].pwm_rate_a = 320; // 3.20 Hz factory_presets[3].pwm_depth_a = 58; factory_presets[3].pwm_offset_a = 0; factory_presets[3].pwm_param_b = 180; factory_presets[3].pwm_depth_b = 58; factory_presets[3].pwm_offset_b = 0; factory_presets[3].pwm_sync = 3; factory_presets[3].amp_mode = hoover::AMP_MODE_STAB; factory_presets[3].amp_attack_ms = 3; factory_presets[3].amp_decay_ms = 600; factory_presets[3].amp_sustain_pct = 0; factory_presets[3].amp_release_ms = 50; factory_presets[3].amp_peak_v = 5; factory_presets[3].amp_curve = hoover::CURVE_EXPONENTIAL; factory_presets[3].flt_mode = hoover::FLT_MODE_AD; factory_presets[3].flt_attack_ms = 3; factory_presets[3].flt_decay_ms = 540; factory_presets[3].flt_sustain_pct = 0; factory_presets[3].flt_release_ms = 50; factory_presets[3].flt_amount_pct = 80; factory_presets[3].flt_base_v = 2; factory_presets[3].flt_curve = hoover::CURVE_EXPONENTIAL; factory_presets[3].aux_mode = hoover::AUX_MOD_LFO3; factory_presets[3].aux_param = 25; factory_presets[3].macro_dest = hoover::MACRO_DETUNE; factory_presets[3].macro_amount = 50; factory_presets[3].vel_dest = hoover::VEL_AMP_LEVEL; // Preset 4: Ultra Swarm (Lush Wide Supersaw / Trance Swarm Lead) snprintf(factory_presets[4].name, 12, "%s", "Ultra Swarm"); factory_presets[4].pitch_amount_st = 2; factory_presets[4].pitch_rise_ms = 80; factory_presets[4].pitch_fall_ms = 1200; factory_presets[4].pitch_mode = hoover::PITCH_MODE_RISE_FALL; factory_presets[4].pitch_curve = hoover::CURVE_LINEAR; factory_presets[4].pitch_retrig = hoover::RETRIG_SMOOTH; factory_presets[4].pitch_lfo_rate = 90; // 0.90 Hz slow chorus factory_presets[4].pitch_lfo_depth = 35; // 35 cents wide beating factory_presets[4].pitch_lfo_spread = 1; factory_presets[4].sub_octave = hoover::SUB_OCT_MINUS_1; factory_presets[4].sub_env_share = 15; factory_presets[4].glide_ms = 60; factory_presets[4].quantize = 0; factory_presets[4].vco2_interval = 0; factory_presets[4].vco2_detune = 24; // +24 cents factory_presets[4].pwm_mode = hoover::PWM_DETUNED; factory_presets[4].pwm_wave = hoover::PWM_WAVE_TRI; factory_presets[4].pwm_rate_a = 75; // 0.75 Hz factory_presets[4].pwm_depth_a = 42; factory_presets[4].pwm_offset_a = 0; factory_presets[4].pwm_param_b = 115; // 1.15x ratio factory_presets[4].pwm_depth_b = 42; factory_presets[4].pwm_offset_b = 0; factory_presets[4].pwm_sync = 0; factory_presets[4].amp_mode = hoover::AMP_MODE_HOLD; factory_presets[4].amp_attack_ms = 50; factory_presets[4].amp_decay_ms = 650; factory_presets[4].amp_sustain_pct = 85; factory_presets[4].amp_release_ms = 700; factory_presets[4].amp_peak_v = 5; factory_presets[4].amp_curve = hoover::CURVE_LINEAR; factory_presets[4].flt_mode = hoover::FLT_MODE_ADSR; factory_presets[4].flt_attack_ms = 80; factory_presets[4].flt_decay_ms = 850; factory_presets[4].flt_sustain_pct = 60; factory_presets[4].flt_release_ms = 650; factory_presets[4].flt_amount_pct = 65; factory_presets[4].flt_base_v = 2; factory_presets[4].flt_curve = hoover::CURVE_LINEAR; factory_presets[4].aux_mode = hoover::AUX_MOD_LFO3; factory_presets[4].aux_param = 10; factory_presets[4].macro_dest = hoover::MACRO_PWM_DEPTH; factory_presets[4].macro_amount = 40; factory_presets[4].vel_dest = hoover::VEL_AMP_LEVEL; // Preset 5: Gabber Dive (Thunderdome Rotterdam Brutal Low Sub-Dive) snprintf(factory_presets[5].name, 12, "%s", "Gabber Dive"); factory_presets[5].pitch_amount_st = -24; // Extreme 2-octave sub plunge factory_presets[5].pitch_rise_ms = 1; factory_presets[5].pitch_fall_ms = 350; factory_presets[5].pitch_mode = hoover::PITCH_MODE_FALL; factory_presets[5].pitch_curve = hoover::CURVE_EXPONENTIAL; factory_presets[5].pitch_retrig = hoover::RETRIG_HARD; factory_presets[5].pitch_lfo_rate = 320; // 3.20 Hz factory_presets[5].pitch_lfo_depth = 38; // 38 cents factory_presets[5].pitch_lfo_spread = 1; factory_presets[5].sub_octave = hoover::SUB_OCT_MINUS_2; // -2 octaves! factory_presets[5].sub_env_share = 50; factory_presets[5].glide_ms = 0; factory_presets[5].quantize = 0; factory_presets[5].vco2_interval = 0; factory_presets[5].vco2_detune = 15; factory_presets[5].pwm_mode = hoover::PWM_LINKED; factory_presets[5].pwm_wave = hoover::PWM_WAVE_TRI; factory_presets[5].pwm_rate_a = 380; // 3.80 Hz factory_presets[5].pwm_depth_a = 62; factory_presets[5].pwm_offset_a = 0; factory_presets[5].pwm_param_b = 90; factory_presets[5].pwm_depth_b = 62; factory_presets[5].pwm_offset_b = 0; factory_presets[5].pwm_sync = 1; factory_presets[5].amp_mode = hoover::AMP_MODE_STAB; factory_presets[5].amp_attack_ms = 1; factory_presets[5].amp_decay_ms = 360; factory_presets[5].amp_sustain_pct = 0; factory_presets[5].amp_release_ms = 30; factory_presets[5].amp_peak_v = 5; factory_presets[5].amp_curve = hoover::CURVE_EXPONENTIAL; factory_presets[5].flt_mode = hoover::FLT_MODE_AD; factory_presets[5].flt_attack_ms = 1; factory_presets[5].flt_decay_ms = 320; factory_presets[5].flt_sustain_pct = 0; factory_presets[5].flt_release_ms = 30; factory_presets[5].flt_amount_pct = 95; factory_presets[5].flt_base_v = 1; factory_presets[5].flt_curve = hoover::CURVE_EXPONENTIAL; factory_presets[5].aux_mode = hoover::AUX_ACCENT_EG; factory_presets[5].aux_param = 80; factory_presets[5].macro_dest = hoover::MACRO_PITCH_AMT; factory_presets[5].macro_amount = 60; factory_presets[5].vel_dest = hoover::VEL_AMP_LEVEL; // Preset 6: Acid Hoover (Resonant Squelch with Octave-Up VCO2) snprintf(factory_presets[6].name, 12, "%s", "Acid Hoover"); factory_presets[6].pitch_amount_st = 7; factory_presets[6].pitch_rise_ms = 3; factory_presets[6].pitch_fall_ms = 380; factory_presets[6].pitch_mode = hoover::PITCH_MODE_FALL; factory_presets[6].pitch_curve = hoover::CURVE_EXPONENTIAL; factory_presets[6].pitch_retrig = hoover::RETRIG_HARD; factory_presets[6].pitch_lfo_rate = 210; factory_presets[6].pitch_lfo_depth = 22; factory_presets[6].pitch_lfo_spread = 1; factory_presets[6].sub_octave = hoover::SUB_OCT_MINUS_1; factory_presets[6].sub_env_share = 20; factory_presets[6].glide_ms = 20; factory_presets[6].quantize = 0; factory_presets[6].vco2_interval = 12; // Octave up (+12 st) factory_presets[6].vco2_detune = 12; factory_presets[6].pwm_mode = hoover::PWM_INDEPENDENT; factory_presets[6].pwm_wave = hoover::PWM_WAVE_TRI; factory_presets[6].pwm_rate_a = 180; factory_presets[6].pwm_depth_a = 45; factory_presets[6].pwm_offset_a = 0; factory_presets[6].pwm_param_b = 240; factory_presets[6].pwm_depth_b = 45; factory_presets[6].pwm_offset_b = 0; factory_presets[6].pwm_sync = 0; factory_presets[6].amp_mode = hoover::AMP_MODE_STAB; factory_presets[6].amp_attack_ms = 2; factory_presets[6].amp_decay_ms = 400; factory_presets[6].amp_sustain_pct = 0; factory_presets[6].amp_release_ms = 40; factory_presets[6].amp_peak_v = 5; factory_presets[6].amp_curve = hoover::CURVE_EXPONENTIAL; factory_presets[6].flt_mode = hoover::FLT_MODE_AD; factory_presets[6].flt_attack_ms = 2; factory_presets[6].flt_decay_ms = 280; factory_presets[6].flt_sustain_pct = 0; factory_presets[6].flt_release_ms = 40; factory_presets[6].flt_amount_pct = 95; factory_presets[6].flt_base_v = 1; factory_presets[6].flt_curve = hoover::CURVE_EXPONENTIAL; factory_presets[6].aux_mode = hoover::AUX_MOD_LFO3; factory_presets[6].aux_param = 30; factory_presets[6].macro_dest = hoover::MACRO_FLT_CUTOFF; factory_presets[6].macro_amount = 70; factory_presets[6].vel_dest = hoover::VEL_FLT_AMOUNT; // Preset 7: Juno Chorus (Pure Alpha Juno BBD Chorus - No Pitch Dive) snprintf(factory_presets[7].name, 12, "%s", "Juno Chorus"); factory_presets[7].pitch_amount_st = 0; // Pure steady pitch factory_presets[7].pitch_rise_ms = 1; factory_presets[7].pitch_fall_ms = 100; factory_presets[7].pitch_mode = hoover::PITCH_MODE_FALL; factory_presets[7].pitch_curve = hoover::CURVE_LINEAR; factory_presets[7].pitch_retrig = hoover::RETRIG_SMOOTH; factory_presets[7].pitch_lfo_rate = 85; // 0.85 Hz classic Juno chorus speed factory_presets[7].pitch_lfo_depth = 24; // 24 cents deep chorus wobble factory_presets[7].pitch_lfo_spread = 1; // Antiphase factory_presets[7].sub_octave = hoover::SUB_OCT_MINUS_1; factory_presets[7].sub_env_share = 0; factory_presets[7].glide_ms = 0; factory_presets[7].quantize = 0; factory_presets[7].vco2_interval = 0; factory_presets[7].vco2_detune = 10; factory_presets[7].pwm_mode = hoover::PWM_LINKED; factory_presets[7].pwm_wave = hoover::PWM_WAVE_SINE; factory_presets[7].pwm_rate_a = 60; // 0.60 Hz slow PWM factory_presets[7].pwm_depth_a = 38; factory_presets[7].pwm_offset_a = 0; factory_presets[7].pwm_param_b = 180; factory_presets[7].pwm_depth_b = 38; factory_presets[7].pwm_offset_b = 0; factory_presets[7].pwm_sync = 0; factory_presets[7].amp_mode = hoover::AMP_MODE_HOLD; factory_presets[7].amp_attack_ms = 100; factory_presets[7].amp_decay_ms = 700; factory_presets[7].amp_sustain_pct = 80; factory_presets[7].amp_release_ms = 550; factory_presets[7].amp_peak_v = 5; factory_presets[7].amp_curve = hoover::CURVE_LINEAR; factory_presets[7].flt_mode = hoover::FLT_MODE_ADSR; factory_presets[7].flt_attack_ms = 150; factory_presets[7].flt_decay_ms = 950; factory_presets[7].flt_sustain_pct = 55; factory_presets[7].flt_release_ms = 700; factory_presets[7].flt_amount_pct = 50; factory_presets[7].flt_base_v = 2; factory_presets[7].flt_curve = hoover::CURVE_LINEAR; factory_presets[7].aux_mode = hoover::AUX_MOD_LFO3; factory_presets[7].aux_param = 15; factory_presets[7].macro_dest = hoover::MACRO_PWM_DEPTH; factory_presets[7].macro_amount = 50; factory_presets[7].vel_dest = hoover::VEL_AMP_LEVEL; // Initialize User Presets with copies of factory presets for (uint8_t u = 0; u < hoover::NUM_USER_PRESETS; ++u) { user_presets[u] = factory_presets[u % hoover::NUM_FACTORY_PRESETS]; snprintf(user_presets[u].name, 12, "User %d", u + 1); } } FLASHMEM void AppHoover::LoadPreset(uint8_t idx) { if (idx < hoover::NUM_FACTORY_PRESETS) { p = factory_presets[idx]; } else if (idx < hoover::TOTAL_PRESETS) { p = user_presets[idx - hoover::NUM_FACTORY_PRESETS]; } active_preset_idx = idx; selected_load_idx = idx; snprintf(status_msg, 16, "Loaded: %.8s", p.name); status_msg_timer = millis() + 2000; } FLASHMEM void AppHoover::SaveUserPreset(uint8_t user_idx) { if (user_idx < hoover::NUM_USER_PRESETS) { user_presets[user_idx] = p; snprintf(user_presets[user_idx].name, 12, "User %d", user_idx + 1); active_preset_idx = hoover::NUM_FACTORY_PRESETS + user_idx; target_user_slot = user_idx; OC::SaveAppData(); snprintf(status_msg, 16, "Saved User %d", user_idx + 1); status_msg_timer = millis() + 2500; } } FLASHMEM void AppHoover::RandomizePatch() { static uint32_t rng = 0x87654321; rng ^= (uint32_t)micros(); auto rnd = [](uint32_t min_v, uint32_t max_v) -> uint32_t { rng = rng * 1664525ULL + 1013904223ULL; return min_v + (rng % (max_v - min_v + 1)); }; // 1. Pitch Env: Hoover-typical intervals static const int8_t intervals[] = { 14, 12, 24, 7, 19, -12, -24, 5, -7 }; p.pitch_amount_st = intervals[rnd(0, sizeof(intervals) - 1)]; p.pitch_rise_ms = (p.pitch_amount_st < 0) ? rnd(4, 25) : rnd(1, 15); p.pitch_fall_ms = rnd(180, 950); p.pitch_mode = (p.pitch_rise_ms > 4) ? hoover::PITCH_MODE_RISE_FALL : hoover::PITCH_MODE_FALL; p.pitch_curve = (rnd(0, 10) < 8) ? hoover::CURVE_EXPONENTIAL : hoover::CURVE_LINEAR; p.pitch_retrig = hoover::RETRIG_HARD; // 2. Pitch LFO (Chorus / Vibrato Swarm) p.pitch_lfo_rate = rnd(80, 320); // 0.80 .. 3.20 Hz p.pitch_lfo_depth = rnd(14, 40); // 14 .. 40 cents p.pitch_lfo_spread = 1; // Antiphase dual-VCO chorus // 3. Sub & Slew static const int8_t subs[] = { 0, -1, -1, -2 }; p.sub_octave = subs[rnd(0, 3)]; p.sub_env_share = (p.sub_octave != 0) ? rnd(0, 40) : 0; p.glide_ms = (rnd(0, 10) < 3) ? rnd(15, 60) : 0; p.quantize = 0; // 4. VCO2 static const int8_t vco2_ints[] = { 0, 0, 0, 7, 7, 12, -12 }; p.vco2_interval = vco2_ints[rnd(0, sizeof(vco2_ints) - 1)]; p.vco2_detune = rnd(10, 24); // 10 .. 24 cents // 5. PWM p.pwm_mode = (hoover::PwmLinkMode)rnd(0, 2); p.pwm_wave = (rnd(0, 10) < 7) ? hoover::PWM_WAVE_TRI : hoover::PWM_WAVE_SINE; p.pwm_rate_a = rnd(80, 320); p.pwm_depth_a = rnd(35, 60); p.pwm_offset_a = 0; p.pwm_param_b = (p.pwm_mode == hoover::PWM_LINKED) ? (rnd(1, 3) * 90) : rnd(90, 125); p.pwm_depth_b = rnd(35, 60); p.pwm_offset_b = 0; p.pwm_sync = (rnd(0, 10) < 6) ? 3 : 0; // 6. Amp p.amp_mode = (rnd(0, 10) < 7) ? hoover::AMP_MODE_STAB : hoover::AMP_MODE_HOLD; p.amp_attack_ms = rnd(1, 8); p.amp_decay_ms = rnd(240, 850); p.amp_sustain_pct = (p.amp_mode == hoover::AMP_MODE_HOLD) ? rnd(50, 85) : 0; p.amp_release_ms = rnd(50, 400); p.amp_peak_v = 5; p.amp_curve = hoover::CURVE_EXPONENTIAL; // 7. Filter p.flt_mode = (hoover::FltMode)p.amp_mode; p.flt_attack_ms = rnd(1, 10); p.flt_decay_ms = rnd(200, 750); p.flt_sustain_pct = p.amp_sustain_pct; p.flt_release_ms = p.amp_release_ms; p.flt_amount_pct = rnd(65, 95); p.flt_base_v = rnd(1, 2); p.flt_curve = hoover::CURVE_EXPONENTIAL; // 8. Aux & Macro p.aux_mode = hoover::AUX_MOD_LFO3; p.aux_param = rnd(15, 60); p.macro_dest = hoover::MACRO_FLT_CUTOFF; p.macro_amount = 50; p.vel_dest = hoover::VEL_AMP_LEVEL; static uint8_t rnd_idx = 1; snprintf(p.name, 12, "Rnd %d", rnd_idx++); if (rnd_idx > 99) rnd_idx = 1; snprintf(status_msg, 16, "Random Sound!"); status_msg_timer = millis() + 2500; } FLASHMEM void AppHoover::Init() { InitFactoryPresets(); LoadPreset(0); BaseStart(); } FLASHMEM void AppHoover::Start() { first_run = true; last_gate = false; manual_gate = false; } void AppHoover::Process(OC::IOFrame *ioframe) { BaseController(ioframe); } void AppHoover::Loop() { // Routine background maintenance } FLASHMEM void AppHoover::HandleAppEvent(OC::AppEvent event) { if (event == OC::APP_EVENT_RESUME) { Resume(); } } FLASHMEM void AppHoover::GetIOConfig(OC::IOConfig &ioconfig) const { ioconfig.digital_inputs[0].set("Gate 1 (TR1)"); ioconfig.digital_inputs[1].set("Clock 2 (TR2)"); ioconfig.digital_inputs[2].set("-"); ioconfig.digital_inputs[3].set("-"); ioconfig.cv[0].set("Note CV 1"); ioconfig.cv[1].set("Macro CV 2"); ioconfig.cv[2].set("Vel CV 3"); ioconfig.cv[3].set("-"); static const char *const out_labels[8] = { "1:VCO1 Pitch", "2:VCO2 Pitch", "3:Sub Pitch", "4:Amp EG", "5:PWM A", "6:PWM B", "7:Filter EG", "8:Aux Out" }; for (int i = 0; i < 8; ++i) { ioconfig.outputs[i].set(out_labels[i], OC::OUTPUT_MODE_PITCH); } } FLASHMEM size_t AppHoover::SaveAppData(util::StreamBufferWriter &sb) const { sb.Write(0x49); // 'I' sb.Write(active_preset_idx); sb.Write(target_user_slot); for (uint8_t u = 0; u < hoover::NUM_USER_PRESETS; ++u) { const hoover::Preset &up = user_presets[u]; for (int i = 0; i < 12; ++i) sb.Write(up.name[i]); sb.Write(up.pitch_amount_st); sb.Write(up.pitch_rise_ms); sb.Write(up.pitch_fall_ms); sb.Write(up.pitch_mode); sb.Write(up.pitch_curve); sb.Write(up.pitch_retrig); sb.Write(up.pitch_lfo_rate); sb.Write(up.pitch_lfo_depth); sb.Write(up.pitch_lfo_spread); sb.Write(up.sub_octave); sb.Write(up.sub_env_share); sb.Write(up.glide_ms); sb.Write(up.quantize); sb.Write(up.vco2_interval); sb.Write(up.vco2_detune); sb.Write(up.pwm_mode); sb.Write(up.pwm_wave); sb.Write(up.pwm_rate_a); sb.Write(up.pwm_depth_a); sb.Write(up.pwm_offset_a); sb.Write(up.pwm_param_b); sb.Write(up.pwm_depth_b); sb.Write(up.pwm_offset_b); sb.Write(up.pwm_sync); sb.Write(up.amp_mode); sb.Write(up.amp_attack_ms); sb.Write(up.amp_decay_ms); sb.Write(up.amp_sustain_pct); sb.Write(up.amp_release_ms); sb.Write(up.amp_peak_v); sb.Write(up.amp_curve); sb.Write(up.flt_mode); sb.Write(up.flt_attack_ms); sb.Write(up.flt_decay_ms); sb.Write(up.flt_sustain_pct); sb.Write(up.flt_release_ms); sb.Write(up.flt_amount_pct); sb.Write(up.flt_base_v); sb.Write(up.flt_curve); sb.Write(up.aux_mode); sb.Write(up.aux_param); sb.Write(up.macro_dest); sb.Write(up.macro_amount); sb.Write(up.vel_dest); } return sb.written(); } FLASHMEM size_t AppHoover::RestoreAppData(util::StreamBufferReader &sb) { InitFactoryPresets(); uint8_t magic = sb.Read(); if (magic != 0x49) { LoadPreset(0); return sb.read(); } uint8_t act = sb.Read(); target_user_slot = sb.Read() % hoover::NUM_USER_PRESETS; for (uint8_t u = 0; u < hoover::NUM_USER_PRESETS; ++u) { hoover::Preset &up = user_presets[u]; for (int i = 0; i < 12; ++i) up.name[i] = sb.Read(); up.name[11] = '\0'; up.pitch_amount_st = sb.Read(); up.pitch_rise_ms = sb.Read(); up.pitch_fall_ms = sb.Read(); up.pitch_mode = sb.Read(); up.pitch_curve = sb.Read(); up.pitch_retrig = sb.Read(); up.pitch_lfo_rate = sb.Read(); up.pitch_lfo_depth = sb.Read(); up.pitch_lfo_spread = sb.Read(); up.sub_octave = sb.Read(); up.sub_env_share = sb.Read(); up.glide_ms = sb.Read(); up.quantize = sb.Read(); up.vco2_interval = sb.Read(); up.vco2_detune = sb.Read(); up.pwm_mode = sb.Read(); up.pwm_wave = sb.Read(); up.pwm_rate_a = sb.Read(); up.pwm_depth_a = sb.Read(); up.pwm_offset_a = sb.Read(); up.pwm_param_b = sb.Read(); up.pwm_depth_b = sb.Read(); up.pwm_offset_b = sb.Read(); up.pwm_sync = sb.Read(); up.amp_mode = sb.Read(); up.amp_attack_ms = sb.Read(); up.amp_decay_ms = sb.Read(); up.amp_sustain_pct = sb.Read(); up.amp_release_ms = sb.Read(); up.amp_peak_v = sb.Read(); up.amp_curve = sb.Read(); up.flt_mode = sb.Read(); up.flt_attack_ms = sb.Read(); up.flt_decay_ms = sb.Read(); up.flt_sustain_pct = sb.Read(); up.flt_release_ms = sb.Read(); up.flt_amount_pct = sb.Read(); up.flt_base_v = sb.Read(); up.flt_curve = sb.Read(); up.aux_mode = sb.Read(); up.aux_param = sb.Read(); up.macro_dest = sb.Read(); up.macro_amount = sb.Read(); up.vel_dest = sb.Read(); } LoadPreset(act < hoover::TOTAL_PRESETS ? act : 0); return sb.read(); } void AppHoover::TriggerEnvelopes() { // Pitch EG Trigger const int32_t target_units = p.pitch_amount_st * hoover::PITCH_PER_SEMITONE; if (p.pitch_mode == hoover::PITCH_MODE_FALL) { if (p.pitch_retrig == hoover::RETRIG_SMOOTH) { pitch_start_val = pitch_curr_val; } else { pitch_start_val = target_units; pitch_curr_val = target_units; } pitch_target_val = 0; pitch_target_ticks = ((uint32_t)p.pitch_fall_ms * hoover::ISR_RATE) / 1000; if (pitch_target_ticks == 0) pitch_target_ticks = 1; pitch_tick = 0; pitch_stage = EG_DECAY; lfo_pitch_phase = 0; } else { // Rise-Fall if (p.pitch_retrig == hoover::RETRIG_SMOOTH) { pitch_start_val = pitch_curr_val; } else { pitch_start_val = 0; pitch_curr_val = 0; } pitch_target_val = target_units; pitch_target_ticks = ((uint32_t)p.pitch_rise_ms * hoover::ISR_RATE) / 1000; if (pitch_target_ticks == 0) pitch_target_ticks = 1; pitch_tick = 0; pitch_stage = EG_ATTACK; } // Amp EG Trigger if (amp_stage != EG_IDLE && p.pitch_retrig == hoover::RETRIG_SMOOTH) { amp_start_level = amp_curr_level; } else { amp_start_level = 0; amp_curr_level = 0; } amp_target_level = 65536; amp_target_ticks = ((uint32_t)p.amp_attack_ms * hoover::ISR_RATE) / 1000; if (amp_target_ticks == 0) amp_target_ticks = 1; amp_tick = 0; amp_stage = EG_ATTACK; // Filter EG Trigger if (flt_stage != EG_IDLE && p.pitch_retrig == hoover::RETRIG_SMOOTH) { flt_start_level = flt_curr_level; } else { flt_start_level = 0; flt_curr_level = 0; } flt_target_level = 65536; flt_target_ticks = ((uint32_t)p.flt_attack_ms * hoover::ISR_RATE) / 1000; if (flt_target_ticks == 0) flt_target_ticks = 1; flt_tick = 0; flt_stage = EG_ATTACK; // Aux Accent Trigger if (p.aux_mode == hoover::AUX_ACCENT_EG) { aux_curr_level = 65536; aux_target_ticks = ((uint32_t)p.aux_param * hoover::ISR_RATE) / 1000; if (aux_target_ticks == 0) aux_target_ticks = 1; aux_tick = 0; aux_stage = EG_DECAY; } // PWM Phase Reset / Key Sync if (p.pwm_sync > 0) { switch (p.pwm_sync) { case 1: lfo_a_phase = 0x00000000; break; // 0 deg case 2: lfo_a_phase = 0x40000000; break; // 90 deg case 3: lfo_a_phase = 0x80000000; break; // 180 deg case 4: lfo_a_phase = 0xC0000000; break; // 270 deg } if (p.pwm_mode != hoover::PWM_LINKED) { lfo_b_phase = lfo_a_phase; } } } void AppHoover::NoteOff() { // In Hold / ADSR mode, enter Release stage if (p.amp_mode == hoover::AMP_MODE_HOLD) { amp_start_level = amp_curr_level; amp_target_level = 0; amp_target_ticks = ((uint32_t)p.amp_release_ms * hoover::ISR_RATE) / 1000; if (amp_target_ticks == 0) amp_target_ticks = 1; amp_tick = 0; amp_stage = EG_RELEASE; } if (p.flt_mode == hoover::FLT_MODE_ADSR) { flt_start_level = flt_curr_level; flt_target_level = 0; flt_target_ticks = ((uint32_t)p.flt_release_ms * hoover::ISR_RATE) / 1000; if (flt_target_ticks == 0) flt_target_ticks = 1; flt_tick = 0; flt_stage = EG_RELEASE; } } void AppHoover::Controller() { // 1. Read Inputs const int raw_cv1 = In(0); const int raw_cv2 = In(1); const int raw_cv3 = In(2); const bool gate_in = Gate(0) || manual_gate; // Clock sync on TR2 if (Clock(1)) { uint32_t now_ticks = millis() * 16; uint32_t delta = now_ticks - last_tr2_tick; last_tr2_tick = now_ticks; if (delta > 800 && delta < 32000) { // 50ms .. 2s tr2_period_ticks = delta; tr2_clock_valid = true; } } // 2. Note CV & Slew / Portamento int target_pitch = raw_cv1; if (p.quantize) { target_pitch = ((raw_cv1 + 64) >> 7) << 7; } if (first_run) { slew_pitch = target_pitch; first_run = false; } else if (p.glide_ms == 0) { slew_pitch = target_pitch; } else { // Slew calculation int32_t diff = target_pitch - slew_pitch; uint32_t shift_coeff = (1000ULL << 16) / ((uint64_t)hoover::ISR_RATE * p.glide_ms); if (shift_coeff > 65536) shift_coeff = 65536; slew_pitch += (diff * (int32_t)shift_coeff) >> 16; } // 3. Gate Transitions & Legato Detection if (gate_in && !last_gate) { TriggerEnvelopes(); last_detected_note = slew_pitch; } else if (!gate_in && last_gate) { NoteOff(); } last_gate = gate_in; // 4. Pitch Envelope Generation if (pitch_stage == EG_ATTACK) { pitch_tick++; if (pitch_tick >= pitch_target_ticks) { pitch_curr_val = pitch_target_val; pitch_start_val = pitch_curr_val; pitch_target_val = 0; pitch_target_ticks = ((uint32_t)p.pitch_fall_ms * hoover::ISR_RATE) / 1000; if (pitch_target_ticks == 0) pitch_target_ticks = 1; pitch_tick = 0; pitch_stage = EG_DECAY; } else { if (p.pitch_curve == hoover::CURVE_LINEAR) { pitch_curr_val = pitch_start_val + (((pitch_target_val - pitch_start_val) * (int32_t)pitch_tick) / (int32_t)pitch_target_ticks); } else { // Smooth exponential-like rise int32_t frac = (pitch_tick << 15) / pitch_target_ticks; int32_t curved = (frac * frac) >> 15; pitch_curr_val = pitch_start_val + (((pitch_target_val - pitch_start_val) * curved) >> 15); } } } else if (pitch_stage == EG_DECAY) { pitch_tick++; if (pitch_tick >= pitch_target_ticks) { pitch_curr_val = 0; pitch_stage = EG_IDLE; } else { if (p.pitch_curve == hoover::CURVE_LINEAR) { pitch_curr_val = pitch_start_val - ((pitch_start_val * (int32_t)pitch_tick) / (int32_t)pitch_target_ticks); } else { // Natural exponential decay int32_t rem = pitch_target_ticks - pitch_tick; int32_t frac = (rem << 15) / pitch_target_ticks; int32_t curved = (frac * frac) >> 15; pitch_curr_val = (pitch_start_val * curved) >> 15; } } } else { pitch_curr_val = 0; } // 5. Amp Envelope Generation if (amp_stage == EG_ATTACK) { amp_tick++; if (amp_tick >= amp_target_ticks) { amp_curr_level = 65536; amp_start_level = 65536; amp_target_level = ((uint32_t)p.amp_sustain_pct * 65536) / 100; amp_target_ticks = ((uint32_t)p.amp_decay_ms * hoover::ISR_RATE) / 1000; if (amp_target_ticks == 0) amp_target_ticks = 1; amp_tick = 0; amp_stage = EG_DECAY; } else { amp_curr_level = amp_start_level + (((amp_target_level - amp_start_level) * amp_tick) / amp_target_ticks); } } else if (amp_stage == EG_DECAY) { amp_tick++; if (amp_tick >= amp_target_ticks) { amp_curr_level = amp_target_level; if (p.amp_mode == hoover::AMP_MODE_HOLD) { amp_stage = EG_SUSTAIN; } else { // In Stab mode, Decay reaches 0 and ends amp_curr_level = 0; amp_stage = EG_IDLE; } } else { if (p.amp_curve == hoover::CURVE_LINEAR) { amp_curr_level = amp_start_level - (((amp_start_level - amp_target_level) * amp_tick) / amp_target_ticks); } else { int32_t rem = amp_target_ticks - amp_tick; int32_t frac = (rem << 15) / amp_target_ticks; int32_t curved = (frac * frac) >> 15; amp_curr_level = amp_target_level + (((amp_start_level - amp_target_level) * curved) >> 15); } } } else if (amp_stage == EG_SUSTAIN) { amp_curr_level = ((uint32_t)p.amp_sustain_pct * 65536) / 100; } else if (amp_stage == EG_RELEASE) { amp_tick++; if (amp_tick >= amp_target_ticks) { amp_curr_level = 0; amp_stage = EG_IDLE; } else { int32_t rem = amp_target_ticks - amp_tick; int32_t frac = (rem << 15) / amp_target_ticks; int32_t curved = (frac * frac) >> 15; amp_curr_level = (amp_start_level * curved) >> 15; } } // 6. Filter Envelope Generation if (flt_stage == EG_ATTACK) { flt_tick++; if (flt_tick >= flt_target_ticks) { flt_curr_level = 65536; flt_start_level = 65536; flt_target_level = ((uint32_t)p.flt_sustain_pct * 65536) / 100; flt_target_ticks = ((uint32_t)p.flt_decay_ms * hoover::ISR_RATE) / 1000; if (flt_target_ticks == 0) flt_target_ticks = 1; flt_tick = 0; flt_stage = EG_DECAY; } else { flt_curr_level = flt_start_level + (((flt_target_level - flt_start_level) * flt_tick) / flt_target_ticks); } } else if (flt_stage == EG_DECAY) { flt_tick++; if (flt_tick >= flt_target_ticks) { flt_curr_level = flt_target_level; if (p.flt_mode == hoover::FLT_MODE_ADSR) { flt_stage = EG_SUSTAIN; } else { flt_curr_level = 0; flt_stage = EG_IDLE; } } else { if (p.flt_curve == hoover::CURVE_LINEAR) { flt_curr_level = flt_start_level - (((flt_start_level - flt_target_level) * flt_tick) / flt_target_ticks); } else { int32_t rem = flt_target_ticks - flt_tick; int32_t frac = (rem << 15) / flt_target_ticks; int32_t curved = (frac * frac) >> 15; flt_curr_level = flt_target_level + (((flt_start_level - flt_target_level) * curved) >> 15); } } } else if (flt_stage == EG_SUSTAIN) { flt_curr_level = ((uint32_t)p.flt_sustain_pct * 65536) / 100; } else if (flt_stage == EG_RELEASE) { flt_tick++; if (flt_tick >= flt_target_ticks) { flt_curr_level = 0; flt_stage = EG_IDLE; } else { int32_t rem = flt_target_ticks - flt_tick; int32_t frac = (rem << 15) / flt_target_ticks; int32_t curved = (frac * frac) >> 15; flt_curr_level = (flt_start_level * curved) >> 15; } } // 7. Aux Envelope Generation (if Accent EG) if (p.aux_mode == hoover::AUX_ACCENT_EG && aux_stage == EG_DECAY) { aux_tick++; if (aux_tick >= aux_target_ticks) { aux_curr_level = 0; aux_stage = EG_IDLE; } else { int32_t rem = aux_target_ticks - aux_tick; aux_curr_level = (rem << 16) / aux_target_ticks; } } // 8. Dual PWM LFOs uint32_t inc_a = ((uint64_t)p.pwm_rate_a * 4294967296ULL) / (100ULL * hoover::ISR_RATE); lfo_a_phase += inc_a; int32_t wave_a = (p.pwm_wave == hoover::PWM_WAVE_SINE) ? hoover::fast_sine(lfo_a_phase) : hoover::fast_triangle(lfo_a_phase); int32_t depth_units_a = (p.pwm_depth_a * 3840) / 100; // 100% = +/- 2.5V int32_t mod_a = (wave_a * depth_units_a) >> 15; int32_t offset_a = (p.pwm_offset_a * hoover::PITCH_PER_OCTAVE) / 1000; int32_t out_pwma = offset_a + mod_a; int32_t out_pwmb = 0; if (p.pwm_mode == hoover::PWM_LINKED) { uint32_t phase_b = lfo_a_phase + ((uint64_t)p.pwm_param_b * 4294967296ULL / 360ULL); int32_t wave_b = (p.pwm_wave == hoover::PWM_WAVE_SINE) ? hoover::fast_sine(phase_b) : hoover::fast_triangle(phase_b); int32_t depth_units_b = (p.pwm_depth_b * 3840) / 100; int32_t mod_b = (wave_b * depth_units_b) >> 15; int32_t offset_b = (p.pwm_offset_b * hoover::PITCH_PER_OCTAVE) / 1000; out_pwmb = offset_b + mod_b; } else if (p.pwm_mode == hoover::PWM_DETUNED) { uint32_t inc_b = (inc_a * p.pwm_param_b) / 100; lfo_b_phase += inc_b; int32_t wave_b = (p.pwm_wave == hoover::PWM_WAVE_SINE) ? hoover::fast_sine(lfo_b_phase) : hoover::fast_triangle(lfo_b_phase); int32_t depth_units_b = (p.pwm_depth_b * 3840) / 100; int32_t mod_b = (wave_b * depth_units_b) >> 15; int32_t offset_b = (p.pwm_offset_b * hoover::PITCH_PER_OCTAVE) / 1000; out_pwmb = offset_b + mod_b; } else { // Independent uint32_t inc_b = ((uint64_t)p.pwm_param_b * 4294967296ULL) / (100ULL * hoover::ISR_RATE); lfo_b_phase += inc_b; int32_t wave_b = (p.pwm_wave == hoover::PWM_WAVE_SINE) ? hoover::fast_sine(lfo_b_phase) : hoover::fast_triangle(lfo_b_phase); int32_t depth_units_b = (p.pwm_depth_b * 3840) / 100; int32_t mod_b = (wave_b * depth_units_b) >> 15; int32_t offset_b = (p.pwm_offset_b * hoover::PITCH_PER_OCTAVE) / 1000; out_pwmb = offset_b + mod_b; } // 8b. Pitch Chorus / Vibrato LFO int32_t pitch_lfo_mod_vco1 = 0; int32_t pitch_lfo_mod_vco2 = 0; if (p.pitch_lfo_depth > 0 && p.pitch_lfo_rate > 0) { uint32_t inc_pitch = ((uint64_t)p.pitch_lfo_rate * 4294967296ULL) / (100ULL * hoover::ISR_RATE); lfo_pitch_phase += inc_pitch; int32_t wave_p1 = hoover::fast_triangle(lfo_pitch_phase); int32_t depth_units = (p.pitch_lfo_depth * hoover::PITCH_PER_SEMITONE) / 100; pitch_lfo_mod_vco1 = (wave_p1 * depth_units) >> 15; if (p.pitch_lfo_spread) { pitch_lfo_mod_vco2 = -pitch_lfo_mod_vco1; // 180 deg out-of-phase chorus } else { pitch_lfo_mod_vco2 = pitch_lfo_mod_vco1; } } // 9. Macro CV Modulation from CV2 & CV3 int32_t macro_val = (raw_cv2 * p.macro_amount) / 100; // 10. Combine 8 Output Signals int32_t out_vals[8]; // OUT 1: VCO 1 Pitch out_vals[0] = slew_pitch + pitch_curr_val + pitch_lfo_mod_vco1; // OUT 2: VCO 2 Pitch (Interval + Detune) int32_t detune_units = (p.vco2_detune * hoover::PITCH_PER_SEMITONE) / 100; if (p.macro_dest == hoover::MACRO_DETUNE) detune_units += macro_val; out_vals[1] = slew_pitch + pitch_curr_val + (p.vco2_interval * hoover::PITCH_PER_SEMITONE) + detune_units + pitch_lfo_mod_vco2; // OUT 3: Sub Pitch int32_t sub_env = (pitch_curr_val * p.sub_env_share) / 100; out_vals[2] = slew_pitch + (p.sub_octave * hoover::PITCH_PER_OCTAVE) + sub_env; // OUT 4: Amp Envelope uint32_t peak_units = (uint32_t)p.amp_peak_v * hoover::PITCH_PER_OCTAVE; if (p.vel_dest == hoover::VEL_AMP_LEVEL && raw_cv3 > 0) { peak_units = (peak_units * (uint32_t)raw_cv3) / hoover::PITCH_PER_OCTAVE; } out_vals[3] = (int32_t)((amp_curr_level * peak_units) >> 16); // OUT 5: PWM A out_vals[4] = out_pwma; // OUT 6: PWM B out_vals[5] = out_pwmb; // OUT 7: Filter Envelope int32_t flt_base = p.flt_base_v * hoover::PITCH_PER_OCTAVE; if (p.macro_dest == hoover::MACRO_FLT_CUTOFF) flt_base += macro_val; int32_t flt_mod = ((int32_t)flt_curr_level * p.flt_amount_pct * (5 * hoover::PITCH_PER_OCTAVE)) / (100LL << 16); out_vals[6] = flt_base + flt_mod; // OUT 8: Auxiliary if (p.aux_mode == hoover::AUX_MOD_LFO3) { uint32_t inc_aux = ((uint64_t)p.aux_param * 4294967296ULL) / (100ULL * hoover::ISR_RATE); lfo_aux_phase += inc_aux; int32_t wave_aux = hoover::fast_triangle(lfo_aux_phase); out_vals[7] = (wave_aux * (2 * hoover::PITCH_PER_OCTAVE)) >> 15; // +/- 2V } else if (p.aux_mode == hoover::AUX_ACCENT_EG) { out_vals[7] = (int32_t)((aux_curr_level * (5 * hoover::PITCH_PER_OCTAVE)) >> 16); } else if (p.aux_mode == hoover::AUX_VELOCITY_CV) { out_vals[7] = raw_cv3; } else if (p.aux_mode == hoover::AUX_MACRO_CV) { out_vals[7] = raw_cv2; } else { out_vals[7] = out_vals[0]; // Pitch Copy } // Safe DAC Output Limiting & Telemetry const int min_p = -OC::DAC::kOctaveZero * hoover::PITCH_PER_OCTAVE; const int max_p = (octave_max - OC::DAC::kOctaveZero) * hoover::PITCH_PER_OCTAVE; bool is_clamped = false; for (int ch = 0; ch < 8; ++ch) { int v = out_vals[ch]; if (v < min_p) { v = min_p; is_clamped = true; } if (v > max_p) { v = max_p; is_clamped = true; } Out(ch, v); out_meters[ch] = v; } output_clamped = is_clamped; } // ============================================================================ // UI & Display Methods (128x64 OLED) // ============================================================================ FLASHMEM void AppHoover::DrawDebugInfo() const {} FLASHMEM void AppHoover::DrawScreensaver() const { DrawMainPage(); } FLASHMEM void AppHoover::DrawMenu() const { View(); } FLASHMEM void AppHoover::View() const { DrawHeader(); switch (current_page) { case PAGE_MAIN: DrawMainPage(); break; case PAGE_PITCH: DrawPitchPage(); break; case PAGE_PWM: DrawPwmPage(); break; case PAGE_AMP: DrawAmpPage(); break; case PAGE_FILTER: DrawFltPage(); break; case PAGE_ROUTING: DrawRoutingPage(); break; case PAGE_PRESETS: DrawPresetsPage(); break; default: break; } } FLASHMEM void AppHoover::DrawHeader() const { static const char *const page_titles[PAGE_COUNT] = { "MAIN", "PITCH", "PWM", "AMP", "FILTER", "ROUTING", "PRESETS" }; graphics.setPrintPos(2, 2); graphics.printf("HV:%s", page_titles[current_page]); // Gate indicator if (last_gate) { graphics.drawRect(86, 1, 14, 8); graphics.setPrintPos(88, 2); graphics.print("G"); } // Clip indicator if (output_clamped) { OrnDeferInvert _inv(true, 102, 1, 24, 8); graphics.setPrintPos(104, 2); graphics.print("CLIP"); } else if (status_msg_timer > millis()) { graphics.setPrintPos(60, 2); graphics.print(status_msg); } graphics.drawHLine(0, 10, 128); } FLASHMEM void AppHoover::DrawMainPage() const { // Line 1: Note name & Pitch Env int semitones = last_detected_note >> 7; int oct = (semitones / 12) + 3; int note = semitones % 12; if (note < 0) { note += 12; oct--; } int cents = ((last_detected_note & 0x7F) * 100) / 128; graphics.setPrintPos(2, 13); graphics.printf("NOTE:%s%d%+dc", OC::Strings::note_names[note], oct, cents); graphics.setPrintPos(76, 13); graphics.printf("P:%+dst %dms", p.pitch_amount_st, p.pitch_fall_ms); // Line 2: Amp & PWM graphics.setPrintPos(2, 23); graphics.printf("AMP:%s %dms", p.amp_mode == hoover::AMP_MODE_STAB ? "STAB" : "HOLD", p.amp_decay_ms); graphics.setPrintPos(76, 23); graphics.printf("PWM:%d.%02dHz %d%%", p.pwm_rate_a / 100, p.pwm_rate_a % 100, p.pwm_depth_a); // Line 3: Filter & Preset graphics.setPrintPos(2, 33); graphics.printf("FLT:%+d%% %dms", p.flt_amount_pct, p.flt_decay_ms); graphics.setPrintPos(76, 33); graphics.printf("[%.7s]", p.name); graphics.drawHLine(0, 42, 128); // Real-Time 8-Channel CV Meters // Channels 1..8: x coords spaced across 128 px for (int ch = 0; ch < 8; ++ch) { int x = 4 + ch * 15; // Channel label graphics.setPrintPos(x + 2, 55); graphics.printf("%d", ch + 1); // Meter box (width 8, height 11, y 43..53) graphics.drawFrame(x, 43, 8, 11); // Calculate bar height based on voltage (-3V..+6V range mapped to 0..9 px) int v = out_meters[ch]; int bar_h = constrain((v + 3 * hoover::PITCH_PER_OCTAVE) * 9 / (8 * hoover::PITCH_PER_OCTAVE), 0, 9); if (bar_h > 0) { graphics.drawRect(x + 1, 53 - bar_h, 6, bar_h); } } } FLASHMEM void AppHoover::DrawPitchPage() const { static const char *const items[] = { "Amount", "Rise", "Fall", "Mode", "Curve", "Retrig", "LFO Rate", "LFO Dpth", "LFO Sprd", "Sub Oct", "Sub Env", "Glide", "Quantize" }; static constexpr int NUM_ITEMS = 13; int start_idx = constrain(cursor - 2, 0, NUM_ITEMS - 5); for (int i = 0; i < 5; ++i) { int idx = start_idx + i; int y = 13 + i * 10; if (idx == cursor) graphics.drawRect(0, y - 1, 128, 9); OrnDeferInvert _inv(idx == cursor && isEditing, 0, y - 1, 128, 9); graphics.setPrintPos(3, y); graphics.printf("%c%s:", (idx == cursor) ? '>' : ' ', items[idx]); graphics.setPrintPos(68, y); switch (idx) { case 0: graphics.printf("%+d st", p.pitch_amount_st); break; case 1: graphics.printf("%d ms", p.pitch_rise_ms); break; case 2: graphics.printf("%d ms", p.pitch_fall_ms); break; case 3: graphics.print(p.pitch_mode == hoover::PITCH_MODE_FALL ? "Fall" : "Rise-Fall"); break; case 4: graphics.print(p.pitch_curve == hoover::CURVE_LINEAR ? "Linear" : "Exp"); break; case 5: graphics.print(p.pitch_retrig == hoover::RETRIG_SMOOTH ? "Smooth" : "Hard"); break; case 6: graphics.printf("%d.%02d Hz", p.pitch_lfo_rate / 100, p.pitch_lfo_rate % 100); break; case 7: graphics.printf("%d cents", p.pitch_lfo_depth); break; case 8: graphics.print(p.pitch_lfo_spread ? "AntiPhase" : "In-Phase"); break; case 9: if (p.sub_octave == hoover::SUB_OCT_OFF) graphics.print("Off"); else graphics.printf("%d Oct", p.sub_octave); break; case 10: graphics.printf("%d %%", p.sub_env_share); break; case 11: graphics.printf("%d ms", p.glide_ms); break; case 12: graphics.print(p.quantize ? "Semitone" : "Continuous"); break; } } } FLASHMEM void AppHoover::DrawPwmPage() const { static const char *const items[] = { "Link Mode", "Waveform", "Rate A", "Depth A", "Offset A", "Param B", "Depth B", "Offset B", "Key Sync" }; static constexpr int NUM_ITEMS = 9; int start_idx = constrain(cursor - 2, 0, NUM_ITEMS - 5); for (int i = 0; i < 5; ++i) { int idx = start_idx + i; int y = 13 + i * 10; if (idx == cursor) graphics.drawRect(0, y - 1, 128, 9); OrnDeferInvert _inv(idx == cursor && isEditing, 0, y - 1, 128, 9); graphics.setPrintPos(3, y); graphics.printf("%c%s:", (idx == cursor) ? '>' : ' ', items[idx]); graphics.setPrintPos(68, y); switch (idx) { case 0: if (p.pwm_mode == hoover::PWM_LINKED) graphics.print("Linked"); else if (p.pwm_mode == hoover::PWM_DETUNED) graphics.print("Detuned"); else graphics.print("Indep"); break; case 1: graphics.print(p.pwm_wave == hoover::PWM_WAVE_TRI ? "Triangle" : "Sine"); break; case 2: graphics.printf("%d.%02d Hz", p.pwm_rate_a / 100, p.pwm_rate_a % 100); break; case 3: graphics.printf("%d %%", p.pwm_depth_a); break; case 4: graphics.printf("%+d mV", p.pwm_offset_a); break; case 5: if (p.pwm_mode == hoover::PWM_LINKED) graphics.printf("%d deg", p.pwm_param_b); else if (p.pwm_mode == hoover::PWM_DETUNED) graphics.printf("%d.%02dx", p.pwm_param_b / 100, p.pwm_param_b % 100); else graphics.printf("%d.%02d Hz", p.pwm_param_b / 100, p.pwm_param_b % 100); break; case 6: graphics.printf("%d %%", p.pwm_depth_b); break; case 7: graphics.printf("%+d mV", p.pwm_offset_b); break; case 8: if (p.pwm_sync == 0) graphics.print("Off"); else graphics.printf("%d deg", (p.pwm_sync - 1) * 90); break; } } } FLASHMEM void AppHoover::DrawAmpPage() const { static const char *const items[] = { "Mode", "Attack", "Decay", "Sustain", "Release", "Peak Level", "Curve" }; static constexpr int NUM_ITEMS = 7; int start_idx = constrain(cursor - 2, 0, NUM_ITEMS - 5); for (int i = 0; i < 5; ++i) { int idx = start_idx + i; int y = 13 + i * 10; if (idx == cursor) graphics.drawRect(0, y - 1, 128, 9); OrnDeferInvert _inv(idx == cursor && isEditing, 0, y - 1, 128, 9); graphics.setPrintPos(3, y); graphics.printf("%c%s:", (idx == cursor) ? '>' : ' ', items[idx]); graphics.setPrintPos(68, y); switch (idx) { case 0: graphics.print(p.amp_mode == hoover::AMP_MODE_STAB ? "Stab (AD)" : "Hold (ADSR)"); break; case 1: graphics.printf("%d ms", p.amp_attack_ms); break; case 2: graphics.printf("%d ms", p.amp_decay_ms); break; case 3: graphics.printf("%d %%", p.amp_sustain_pct); break; case 4: graphics.printf("%d ms", p.amp_release_ms); break; case 5: graphics.printf("%d.0 V", p.amp_peak_v); break; case 6: graphics.print(p.amp_curve == hoover::CURVE_LINEAR ? "Linear" : "Exp"); break; } } } FLASHMEM void AppHoover::DrawFltPage() const { static const char *const items[] = { "Mode", "Attack", "Decay", "Sustain", "Release", "Amount", "Base Cutoff", "Curve" }; static constexpr int NUM_ITEMS = 8; int start_idx = constrain(cursor - 2, 0, NUM_ITEMS - 5); for (int i = 0; i < 5; ++i) { int idx = start_idx + i; int y = 13 + i * 10; if (idx == cursor) graphics.drawRect(0, y - 1, 128, 9); OrnDeferInvert _inv(idx == cursor && isEditing, 0, y - 1, 128, 9); graphics.setPrintPos(3, y); graphics.printf("%c%s:", (idx == cursor) ? '>' : ' ', items[idx]); graphics.setPrintPos(68, y); switch (idx) { case 0: graphics.print(p.flt_mode == hoover::FLT_MODE_AD ? "AD (Stab)" : "ADSR (Hold)"); break; case 1: graphics.printf("%d ms", p.flt_attack_ms); break; case 2: graphics.printf("%d ms", p.flt_decay_ms); break; case 3: graphics.printf("%d %%", p.flt_sustain_pct); break; case 4: graphics.printf("%d ms", p.flt_release_ms); break; case 5: graphics.printf("%+d %%", p.flt_amount_pct); break; case 6: graphics.printf("%d.0 V", p.flt_base_v); break; case 7: graphics.print(p.flt_curve == hoover::CURVE_LINEAR ? "Linear" : "Exp"); break; } } } FLASHMEM void AppHoover::DrawRoutingPage() const { static const char *const items[] = { "VCO2 Intvl", "VCO2 Detune", "Aux Mode", "Aux Param", "Macro Dest", "Macro Amt", "Vel Dest" }; static constexpr int NUM_ITEMS = 7; int start_idx = constrain(cursor - 2, 0, NUM_ITEMS - 5); for (int i = 0; i < 5; ++i) { int idx = start_idx + i; int y = 13 + i * 10; if (idx == cursor) graphics.drawRect(0, y - 1, 128, 9); OrnDeferInvert _inv(idx == cursor && isEditing, 0, y - 1, 128, 9); graphics.setPrintPos(3, y); graphics.printf("%c%s:", (idx == cursor) ? '>' : ' ', items[idx]); graphics.setPrintPos(68, y); switch (idx) { case 0: graphics.printf("%+d st", p.vco2_interval); break; case 1: graphics.printf("%+d c", p.vco2_detune); break; case 2: switch (p.aux_mode) { case hoover::AUX_MOD_LFO3: graphics.print("Mod LFO 3"); break; case hoover::AUX_ACCENT_EG: graphics.print("Accent EG"); break; case hoover::AUX_VELOCITY_CV: graphics.print("Vel CV3"); break; case hoover::AUX_MACRO_CV: graphics.print("Macro CV2"); break; case hoover::AUX_PITCH_COPY: graphics.print("Pitch Copy"); break; } break; case 3: if (p.aux_mode == hoover::AUX_MOD_LFO3) graphics.printf("%d.%02d Hz", p.aux_param / 100, p.aux_param % 100); else if (p.aux_mode == hoover::AUX_ACCENT_EG) graphics.printf("%d ms", p.aux_param); else graphics.print("-"); break; case 4: switch (p.macro_dest) { case hoover::MACRO_NONE: graphics.print("None"); break; case hoover::MACRO_FLT_CUTOFF: graphics.print("Cutoff"); break; case hoover::MACRO_PITCH_AMT: graphics.print("Pitch Amt"); break; case hoover::MACRO_PWM_DEPTH: graphics.print("PWM Depth"); break; case hoover::MACRO_DETUNE: graphics.print("Detune"); break; case hoover::MACRO_DECAY_TIME: graphics.print("Decay"); break; } break; case 5: graphics.printf("%+d %%", p.macro_amount); break; case 6: switch (p.vel_dest) { case hoover::VEL_NONE: graphics.print("None"); break; case hoover::VEL_AMP_LEVEL: graphics.print("Amp Level"); break; case hoover::VEL_FLT_AMOUNT: graphics.print("Filter Amt"); break; case hoover::VEL_PITCH_AMOUNT: graphics.print("Pitch Amt"); break; } break; } } } FLASHMEM void AppHoover::DrawPresetsPage() const { for (int i = 0; i < 4; ++i) { int y = 13 + i * 10; if (i == cursor) graphics.drawRect(0, y - 1, 128, 9); OrnDeferInvert _inv(i == cursor && isEditing, 0, y - 1, 128, 9); graphics.setPrintPos(3, y); graphics.printf("%c", (i == cursor) ? '>' : ' '); switch (i) { case 0: { const char *pname = (selected_load_idx < hoover::NUM_FACTORY_PRESETS) ? factory_presets[selected_load_idx].name : user_presets[selected_load_idx - hoover::NUM_FACTORY_PRESETS].name; char tag = (selected_load_idx < hoover::NUM_FACTORY_PRESETS) ? 'F' : 'U'; int num = (selected_load_idx < hoover::NUM_FACTORY_PRESETS) ? (selected_load_idx + 1) : (selected_load_idx - hoover::NUM_FACTORY_PRESETS + 1); graphics.printf("Load: %c%d %-9.9s", tag, num, pname); break; } case 1: graphics.print("Randomize Sound"); break; case 2: graphics.printf("Target Slot: User %d", target_user_slot + 1); break; case 3: graphics.printf("Save Patch -> User %d", target_user_slot + 1); break; } } // Footer Actions graphics.setPrintPos(3, 54); switch (cursor) { case 0: graphics.print("[CLICK R: LOAD]"); break; case 1: graphics.print("[CLICK R: RANDOMIZE]"); break; case 2: graphics.print("[TURN R: CHANGE SLOT]"); break; case 3: graphics.print("[CLICK R: SAVE]"); break; } } FLASHMEM void AppHoover::HandleButtonEvent(const UI::Event &event) { if (event.type == UI::EVENT_BUTTON_PRESS) { if (event.control == OC::CONTROL_BUTTON_UP) { // Cycle Pages Forward current_page = (current_page + 1) % PAGE_COUNT; cursor = 0; isEditing = false; } else if (event.control == OC::CONTROL_BUTTON_DOWN) { // Manual Note Trigger / Preview manual_gate = !manual_gate; } else if (event.control == OC::CONTROL_BUTTON_L) { // Toggle Edit Mode or Page Select if (current_page == PAGE_MAIN) { current_page = PAGE_PITCH; } else { isEditing = !isEditing; } } else if (event.control == OC::CONTROL_BUTTON_R) { if (current_page == PAGE_PRESETS) { if (cursor == 0) { LoadPreset(selected_load_idx); } else if (cursor == 1) { RandomizePatch(); } else if (cursor == 2) { target_user_slot = (target_user_slot + 1) % hoover::NUM_USER_PRESETS; } else if (cursor == 3) { SaveUserPreset(target_user_slot); } } else { fastEdit = !fastEdit; } } } else if (event.type == UI::EVENT_BUTTON_LONG_PRESS) { if (event.control == OC::CONTROL_BUTTON_R) { // Quick save to target user slot SaveUserPreset(target_user_slot); } else if (event.control == OC::CONTROL_BUTTON_L) { // Quick reload LoadPreset(selected_load_idx); } } } void AppHoover::HandleEncoderEvent(const UI::Event &event) { if (event.control == OC::CONTROL_ENCODER_L) { // Navigate Parameters int max_cursor = 0; switch (current_page) { case PAGE_MAIN: max_cursor = 0; break; case PAGE_PITCH: max_cursor = 12; break; case PAGE_PWM: max_cursor = 8; break; case PAGE_AMP: max_cursor = 6; break; case PAGE_FILTER: max_cursor = 7; break; case PAGE_ROUTING: max_cursor = 6; break; case PAGE_PRESETS: max_cursor = 3; break; default: break; } cursor = constrain(cursor + event.value, 0, max_cursor); } else if (event.control == OC::CONTROL_ENCODER_R) { // Modify Selected Parameter int delta = event.value * (fastEdit ? 10 : 1); switch (current_page) { case PAGE_PITCH: switch (cursor) { case 0: p.pitch_amount_st = constrain(p.pitch_amount_st + event.value, -36, 36); break; case 1: p.pitch_rise_ms = constrain((int)p.pitch_rise_ms + delta, 1, 2000); break; case 2: p.pitch_fall_ms = constrain((int)p.pitch_fall_ms + delta * 2, 10, 5000); break; case 3: p.pitch_mode = (p.pitch_mode + 1) % 2; break; case 4: p.pitch_curve = (p.pitch_curve + 1) % 2; break; case 5: p.pitch_retrig = (p.pitch_retrig + 1) % 2; break; case 6: p.pitch_lfo_rate = constrain((int)p.pitch_lfo_rate + (fastEdit ? event.value * 20 : event.value * 5), 10, 800); break; case 7: p.pitch_lfo_depth = constrain((int)p.pitch_lfo_depth + (fastEdit ? event.value * 5 : event.value), 0, 60); break; case 8: p.pitch_lfo_spread = (p.pitch_lfo_spread + 1) % 2; break; case 9: p.sub_octave = (p.sub_octave == 0) ? -1 : (p.sub_octave == -1 ? -2 : 0); break; case 10: p.sub_env_share = constrain((int)p.sub_env_share + delta, 0, 100); break; case 11: p.glide_ms = constrain((int)p.glide_ms + delta, 0, 2000); break; case 12: p.quantize = (p.quantize + 1) % 2; break; } break; case PAGE_PWM: switch (cursor) { case 0: p.pwm_mode = (p.pwm_mode + 1) % 3; break; case 1: p.pwm_wave = (p.pwm_wave + 1) % 2; break; case 2: p.pwm_rate_a = constrain((int)p.pwm_rate_a + delta * 5, 5, 2000); break; case 3: p.pwm_depth_a = constrain((int)p.pwm_depth_a + delta, 0, 100); break; case 4: p.pwm_offset_a = constrain((int)p.pwm_offset_a + delta * 25, -2500, 2500); break; case 5: if (p.pwm_mode == hoover::PWM_LINKED) p.pwm_param_b = (p.pwm_param_b + delta * 10 + 360) % 360; else if (p.pwm_mode == hoover::PWM_DETUNED) p.pwm_param_b = constrain((int)p.pwm_param_b + event.value, 80, 130); else p.pwm_param_b = constrain((int)p.pwm_param_b + delta * 5, 5, 2000); break; case 6: p.pwm_depth_b = constrain((int)p.pwm_depth_b + delta, 0, 100); break; case 7: p.pwm_offset_b = constrain((int)p.pwm_offset_b + delta * 25, -2500, 2500); break; case 8: p.pwm_sync = (p.pwm_sync + 1) % 5; break; } break; case PAGE_AMP: switch (cursor) { case 0: p.amp_mode = (p.amp_mode + 1) % 2; break; case 1: p.amp_attack_ms = constrain((int)p.amp_attack_ms + delta, 1, 2000); break; case 2: p.amp_decay_ms = constrain((int)p.amp_decay_ms + delta * 2, 10, 5000); break; case 3: p.amp_sustain_pct = constrain((int)p.amp_sustain_pct + delta, 0, 100); break; case 4: p.amp_release_ms = constrain((int)p.amp_release_ms + delta * 2, 10, 5000); break; case 5: p.amp_peak_v = constrain((int)p.amp_peak_v + event.value, 3, 8); break; case 6: p.amp_curve = (p.amp_curve + 1) % 2; break; } break; case PAGE_FILTER: switch (cursor) { case 0: p.flt_mode = (p.flt_mode + 1) % 2; break; case 1: p.flt_attack_ms = constrain((int)p.flt_attack_ms + delta, 1, 2000); break; case 2: p.flt_decay_ms = constrain((int)p.flt_decay_ms + delta * 2, 10, 5000); break; case 3: p.flt_sustain_pct = constrain((int)p.flt_sustain_pct + delta, 0, 100); break; case 4: p.flt_release_ms = constrain((int)p.flt_release_ms + delta * 2, 10, 5000); break; case 5: p.flt_amount_pct = constrain((int)p.flt_amount_pct + delta, -100, 100); break; case 6: p.flt_base_v = constrain((int)p.flt_base_v + event.value, 0, 5); break; case 7: p.flt_curve = (p.flt_curve + 1) % 2; break; } break; case PAGE_ROUTING: switch (cursor) { case 0: p.vco2_interval = constrain(p.vco2_interval + event.value, -24, 24); break; case 1: p.vco2_detune = constrain(p.vco2_detune + event.value, -100, 100); break; case 2: p.aux_mode = (p.aux_mode + 1) % 5; break; case 3: p.aux_param = constrain((int)p.aux_param + delta, 1, 1000); break; case 4: p.macro_dest = (p.macro_dest + 1) % 6; break; case 5: p.macro_amount = constrain((int)p.macro_amount + delta, -100, 100); break; case 6: p.vel_dest = (p.vel_dest + 1) % 4; break; } break; case PAGE_PRESETS: if (cursor == 0) { selected_load_idx = constrain((int)selected_load_idx + event.value, 0, hoover::TOTAL_PRESETS - 1); } else if (cursor == 2) { target_user_slot = constrain((int)target_user_slot + event.value, 0, hoover::NUM_USER_PRESETS - 1); } break; default: break; } } } #endif // ARDUINO_TEENSY41