From 1d6428c2552d66981bf7a89a7644b041fa85a923 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 23:46:23 +0100 Subject: [PATCH 1/5] feat: add LFO2 with selectable destination target - Add lfo2_waveform, lfo2_division, lfo2_depth, lfo2_dest fields to SynthParams - Add Lfo2Waveform, Lfo2Division, Lfo2Depth, Lfo2Dest control field variants - Wire LFO2 DSP modulation in engine for both Synth A and Synth B - Update UI layout: AMP | LFO1 | LFO2 (3-column bottom row) - Update mouse hit-testing for 3-column LFO layout - Update keyboard navigation (AMP + LFO1 + LFO2 in single row) - LFO2 defaults: Sine, 1/4 note, depth 0 (off), dest Osc1Tune (vibrato) - Same 11 assignable destinations as LFO1 - Backward compatible: serde(default) for all new fields Co-Authored-By: Claude Opus 4.6 --- src/audio/engine.rs | 34 +++++++++++++++++ src/keys.rs | 18 +++++---- src/mouse.rs | 25 +++++++++--- src/presets/synth_presets.rs | 1 + src/sequencer/synth_pattern.rs | 55 ++++++++++++++++++++++++--- src/ui/synth_knobs.rs | 69 +++++++++++++++++++++++----------- 6 files changed, 162 insertions(+), 40 deletions(-) diff --git a/src/audio/engine.rs b/src/audio/engine.rs index ace9ca6..c529950 100644 --- a/src/audio/engine.rs +++ b/src/audio/engine.rs @@ -82,6 +82,7 @@ pub struct SynthInstance { pub gate_samples: u32, pub note_end_step: Option, pub lfo: Lfo, + pub lfo2: Lfo, pub saturator: TubeSaturator, pub reverb: ReverbEffect, pub delay: DelayEffect, @@ -100,6 +101,7 @@ impl SynthInstance { gate_samples: 0, note_end_step: None, lfo: Lfo::new(), + lfo2: Lfo::new(), saturator: TubeSaturator::new(sample_rate as f32), reverb: ReverbEffect::new(sample_rate), delay: DelayEffect::new(), @@ -470,6 +472,22 @@ impl AudioEngine { field.set(&mut modulated_params, current + mod_amount); } } + if synth_params.lfo2_depth > 0.001 { + let div_mult = lfo_division_multiplier(synth_params.lfo2_division); + let lfo_val = self.synth_a.lfo2.tick( + self.sample_rate, + self.transport.bpm, + div_mult, + synth_params.lfo2_waveform, + ); + let mod_amount = lfo_val * synth_params.lfo2_depth; + let dest_idx = synth_params.lfo2_dest as usize; + if dest_idx < LFO_DEST_FIELDS.len() { + let field = LFO_DEST_FIELDS[dest_idx]; + let current = field.get(&modulated_params); + field.set(&mut modulated_params, current + mod_amount); + } + } let synth_sample = self.synth_a.voice.tick(&modulated_params); let mut synth_dry: f32 = 0.0; if !self.synth_a.pattern.params.mute { @@ -503,6 +521,22 @@ impl AudioEngine { field.set(&mut modulated_params, current + mod_amount); } } + if synth_params.lfo2_depth > 0.001 { + let div_mult = lfo_division_multiplier(synth_params.lfo2_division); + let lfo_val = self.synth_b.lfo2.tick( + self.sample_rate, + self.transport.bpm, + div_mult, + synth_params.lfo2_waveform, + ); + let mod_amount = lfo_val * synth_params.lfo2_depth; + let dest_idx = synth_params.lfo2_dest as usize; + if dest_idx < LFO_DEST_FIELDS.len() { + let field = LFO_DEST_FIELDS[dest_idx]; + let current = field.get(&modulated_params); + field.set(&mut modulated_params, current + mod_amount); + } + } let synth_sample = self.synth_b.voice.tick(&modulated_params); let mut synth_dry: f32 = 0.0; if !self.synth_b.pattern.params.mute { diff --git a/src/keys.rs b/src/keys.rs index 8a22527..eda26dc 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -939,7 +939,7 @@ fn handle_synth_grid(app: &mut App, key: KeyEvent, synth_id: SynthId) { /// Row 0: OSC1 (left) + OSC2 (right) /// Row 1: ENV1 (left) + ENV2 (middle) + FILT (right) /// Row 2: AMP -const SYNTH_CTRL_ROWS: [&[SynthControlField]; 4] = [ +const SYNTH_CTRL_ROWS: [&[SynthControlField]; 3] = [ // Row 0: OSC1 | OSC2 (side by side visually) &[ SynthControlField::Osc1Waveform, SynthControlField::Osc1Tune, SynthControlField::Osc1Pwm, SynthControlField::Osc1Level, @@ -951,10 +951,12 @@ const SYNTH_CTRL_ROWS: [&[SynthControlField]; 4] = [ SynthControlField::Env2Attack, SynthControlField::Env2Decay, SynthControlField::Env2Sustain, SynthControlField::Env2Release, SynthControlField::FilterType, SynthControlField::FilterCutoff, SynthControlField::FilterResonance, SynthControlField::FilterEnvAmount, SynthControlField::FilterEnvAttack, SynthControlField::FilterEnvDecay, SynthControlField::FilterEnvSustain, SynthControlField::FilterEnvRelease, ], - // Row 2: LFO - &[SynthControlField::LfoWaveform, SynthControlField::LfoDivision, SynthControlField::LfoDepth, SynthControlField::LfoDest], - // Row 3: AMP - &[SynthControlField::Volume, SynthControlField::SendReverb, SynthControlField::SendDelay], + // Row 2: AMP | LFO1 | LFO2 + &[ + SynthControlField::Volume, SynthControlField::SendReverb, SynthControlField::SendDelay, + SynthControlField::LfoWaveform, SynthControlField::LfoDivision, SynthControlField::LfoDepth, SynthControlField::LfoDest, + SynthControlField::Lfo2Waveform, SynthControlField::Lfo2Division, SynthControlField::Lfo2Depth, SynthControlField::Lfo2Dest, + ], ]; /// Find (row, col) of a field in the 2D layout. @@ -1040,9 +1042,9 @@ fn adjust_synth_field(app: &mut App, synth_id: SynthId, delta: f32) { } else if field.is_enum() { let max_val: u8 = match field { SynthControlField::FilterType => 2, - SynthControlField::LfoWaveform => 2, - SynthControlField::LfoDivision => 9, - SynthControlField::LfoDest => (crate::sequencer::synth_pattern::LFO_DEST_FIELDS.len() - 1) as u8, + SynthControlField::LfoWaveform | SynthControlField::Lfo2Waveform => 2, + SynthControlField::LfoDivision | SynthControlField::Lfo2Division => 9, + SynthControlField::LfoDest | SynthControlField::Lfo2Dest => (crate::sequencer::synth_pattern::LFO_DEST_FIELDS.len() - 1) as u8, _ => 3, // Osc1/Osc2 waveforms }; let cur = field.get(&pattern.params); diff --git a/src/mouse.rs b/src/mouse.rs index b952c59..092ac29 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -448,7 +448,7 @@ fn hit_test_synth_knobs(col: u16, row: u16, knobs_area: Rect) -> Option Option Option 0 { let rel_x = (col - cols[1].x) as usize; @@ -572,6 +573,20 @@ fn hit_test_synth_knobs(col: u16, row: u16, knobs_area: Rect) -> Option 0 { + let rel_x = (col - cols[2].x) as usize; + let idx = (rel_x / col_width).min(3); + return match idx { + 0 => Some(SynthControlField::Lfo2Waveform), + 1 => Some(SynthControlField::Lfo2Division), + 2 => Some(SynthControlField::Lfo2Depth), + _ => Some(SynthControlField::Lfo2Dest), + }; + } + return Some(SynthControlField::Lfo2Waveform); } } diff --git a/src/presets/synth_presets.rs b/src/presets/synth_presets.rs index c181a5e..d31fdef 100644 --- a/src/presets/synth_presets.rs +++ b/src/presets/synth_presets.rs @@ -23,6 +23,7 @@ const fn sp( filter_type, filter_cutoff, filter_resonance, filter_env_amount, filter_env_attack: fenv_a, filter_env_decay: fenv_d, filter_env_sustain: fenv_s, filter_env_release: fenv_r, lfo_waveform: 1, lfo_division: 0.47, lfo_depth: 0.0, lfo_dest: 0, + lfo2_waveform: 0, lfo2_division: 0.47, lfo2_depth: 0.0, lfo2_dest: 2, volume, send_reverb: 0.2, send_delay: 0.0, mute: false, diff --git a/src/sequencer/synth_pattern.rs b/src/sequencer/synth_pattern.rs index 10ff9eb..62c33fe 100644 --- a/src/sequencer/synth_pattern.rs +++ b/src/sequencer/synth_pattern.rs @@ -14,6 +14,9 @@ pub struct SynthStep { } fn default_length() -> u8 { 1 } +fn default_lfo2_waveform() -> u8 { 0 } // Sine +fn default_lfo2_division() -> f32 { 0.47 } // ~1/4 note +fn default_lfo2_dest() -> u8 { 2 } // Osc1Tune (pitch vibrato) impl Default for SynthStep { fn default() -> Self { @@ -111,7 +114,7 @@ pub struct SynthParams { #[serde(default)] pub filter_env_release: f32, - // LFO + // LFO1 #[serde(default)] pub lfo_waveform: u8, // 0=Sine, 1=Triangle, 2=SawDn, 3=SawUp, 4=Square, 5=Exp #[serde(default)] @@ -121,6 +124,16 @@ pub struct SynthParams { #[serde(default)] pub lfo_dest: u8, // index into LFO_DEST_FIELDS + // LFO2 + #[serde(default = "default_lfo2_waveform")] + pub lfo2_waveform: u8, + #[serde(default = "default_lfo2_division")] + pub lfo2_division: f32, + #[serde(default)] + pub lfo2_depth: f32, + #[serde(default = "default_lfo2_dest")] + pub lfo2_dest: u8, + // Output #[serde(default)] pub volume: f32, @@ -177,12 +190,18 @@ impl Default for SynthParams { filter_env_sustain: 0.0, filter_env_release: 0.2, - // LFO: off by default + // LFO1: off by default lfo_waveform: 1, // Triangle lfo_division: 0.47, // ~1/4 note lfo_depth: 0.0, lfo_dest: 0, // FilterCutoff + // LFO2: off by default + lfo2_waveform: 0, // Sine + lfo2_division: 0.47, + lfo2_depth: 0.0, + lfo2_dest: 2, // Osc1Tune (pitch vibrato) + // Output volume: 0.8, send_reverb: 0.2, @@ -247,11 +266,16 @@ pub enum SynthControlField { FilterEnvDecay, FilterEnvSustain, FilterEnvRelease, - // LFO + // LFO1 LfoWaveform, LfoDivision, LfoDepth, LfoDest, + // LFO2 + Lfo2Waveform, + Lfo2Division, + Lfo2Depth, + Lfo2Dest, // Output Volume, SendReverb, @@ -260,7 +284,7 @@ pub enum SynthControlField { Mute, } -static ALL_FIELDS: [SynthControlField; 34] = [ +static ALL_FIELDS: [SynthControlField; 38] = [ SynthControlField::Osc1Waveform, SynthControlField::Osc1Tune, SynthControlField::Osc1Pwm, @@ -291,6 +315,10 @@ static ALL_FIELDS: [SynthControlField; 34] = [ SynthControlField::LfoDivision, SynthControlField::LfoDepth, SynthControlField::LfoDest, + SynthControlField::Lfo2Waveform, + SynthControlField::Lfo2Division, + SynthControlField::Lfo2Depth, + SynthControlField::Lfo2Dest, SynthControlField::Volume, SynthControlField::SendReverb, SynthControlField::SendDelay, @@ -330,6 +358,10 @@ impl SynthControlField { Self::LfoDivision => "DV", Self::LfoDepth => "DP", Self::LfoDest => "DS", + Self::Lfo2Waveform => "WV", + Self::Lfo2Division => "DV", + Self::Lfo2Depth => "DP", + Self::Lfo2Dest => "DS", Self::Volume => "VL", Self::SendReverb => "RV", Self::SendDelay => "DL", @@ -369,6 +401,10 @@ impl SynthControlField { Self::LfoDivision => "Div", Self::LfoDepth => "Depth", Self::LfoDest => "Dest", + Self::Lfo2Waveform => "Wave", + Self::Lfo2Division => "Div", + Self::Lfo2Depth => "Depth", + Self::Lfo2Dest => "Dest", Self::Volume => "Vol", Self::SendReverb => "Reverb", Self::SendDelay => "Delay", @@ -408,6 +444,10 @@ impl SynthControlField { Self::LfoDivision => p.lfo_division, Self::LfoDepth => p.lfo_depth, Self::LfoDest => p.lfo_dest as f32 / (LFO_DEST_FIELDS.len() - 1).max(1) as f32, + Self::Lfo2Waveform => p.lfo2_waveform as f32 / (NUM_LFO_WAVEFORMS - 1) as f32, + Self::Lfo2Division => p.lfo2_division, + Self::Lfo2Depth => p.lfo2_depth, + Self::Lfo2Dest => p.lfo2_dest as f32 / (LFO_DEST_FIELDS.len() - 1).max(1) as f32, Self::Volume => p.volume, Self::SendReverb => p.send_reverb, Self::SendDelay => p.send_delay, @@ -448,6 +488,10 @@ impl SynthControlField { Self::LfoDivision => p.lfo_division = v, Self::LfoDepth => p.lfo_depth = v, Self::LfoDest => p.lfo_dest = (v * (LFO_DEST_FIELDS.len() - 1) as f32).round() as u8, + Self::Lfo2Waveform => p.lfo2_waveform = (v * (NUM_LFO_WAVEFORMS - 1) as f32).round() as u8, + Self::Lfo2Division => p.lfo2_division = v, + Self::Lfo2Depth => p.lfo2_depth = v, + Self::Lfo2Dest => p.lfo2_dest = (v * (LFO_DEST_FIELDS.len() - 1) as f32).round() as u8, Self::Volume => p.volume = v, Self::SendReverb => p.send_reverb = v, Self::SendDelay => p.send_delay = v, @@ -461,7 +505,8 @@ impl SynthControlField { pub fn is_enum(&self) -> bool { matches!(self, Self::Osc1Waveform | Self::Osc2Waveform | Self::FilterType - | Self::LfoWaveform | Self::LfoDivision | Self::LfoDest) + | Self::LfoWaveform | Self::LfoDivision | Self::LfoDest + | Self::Lfo2Waveform | Self::Lfo2Division | Self::Lfo2Dest) } } diff --git a/src/ui/synth_knobs.rs b/src/ui/synth_knobs.rs index 0212621..83ea836 100644 --- a/src/ui/synth_knobs.rs +++ b/src/ui/synth_knobs.rs @@ -8,7 +8,7 @@ use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; use crate::app::{App, FocusSection}; use crate::messages::SynthId; -use crate::sequencer::synth_pattern::{SynthControlField, lfo_waveform_name, lfo_division_name, lfo_dest_name}; +use crate::sequencer::synth_pattern::{SynthControlField, lfo_waveform_name, lfo_division_name, lfo_dest_name, SynthParams}; use crate::ui::theme; /// Height of the vertical slider bars. @@ -203,13 +203,14 @@ pub fn render_synth_knobs(f: &mut Frame, area: Rect, app: &App, synth_id: SynthI render_adsr_bars(f, filt_split[1], params, FILT_ENV_ADSR, sel, focused); } - // ── Row group 3: AMP (left) + LFO (right) ───────────────────────── + // ── Row group 3: AMP (left) + LFO1 (center) + LFO2 (right) ──────── { let cols = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Percentage(50), // AMP - Constraint::Percentage(50), // LFO + Constraint::Percentage(34), // AMP + Constraint::Percentage(33), // LFO1 + Constraint::Percentage(33), // LFO2 ]) .split(row_groups[2]); @@ -223,15 +224,25 @@ pub fn render_synth_knobs(f: &mut Frame, area: Rect, app: &App, synth_id: SynthI ); render_amp_group(f, amp_body, params, app.effect_params.synth_saturator_drive, sel, focused); - // LFO section - render_section_header(f, cols[1], "LFO"); - let lfo_body = Rect::new( + // LFO1 section + render_section_header(f, cols[1], "LFO1"); + let lfo1_body = Rect::new( cols[1].x, cols[1].y + 1, cols[1].width, cols[1].height.saturating_sub(1), ); - render_lfo_row(f, lfo_body, params, sel, focused); + render_lfo_row(f, lfo1_body, params, sel, focused, false); + + // LFO2 section + render_section_header(f, cols[2], "LFO2"); + let lfo2_body = Rect::new( + cols[2].x, + cols[2].y + 1, + cols[2].width, + cols[2].height.saturating_sub(1), + ); + render_lfo_row(f, lfo2_body, params, sel, focused, true); } } @@ -520,14 +531,28 @@ fn render_adsr_bars( fn render_lfo_row( f: &mut Frame, area: Rect, - params: &crate::sequencer::synth_pattern::SynthParams, + params: &SynthParams, selected: SynthControlField, focused: bool, + is_lfo2: bool, ) { - if area.height < 2 || area.width < 20 { + if area.height < 2 || area.width < 16 { return; } + let (wave_field, div_field, depth_field, dest_field) = if is_lfo2 { + (SynthControlField::Lfo2Waveform, SynthControlField::Lfo2Division, + SynthControlField::Lfo2Depth, SynthControlField::Lfo2Dest) + } else { + (SynthControlField::LfoWaveform, SynthControlField::LfoDivision, + SynthControlField::LfoDepth, SynthControlField::LfoDest) + }; + let (waveform, division, depth, dest) = if is_lfo2 { + (params.lfo2_waveform, params.lfo2_division, params.lfo2_depth, params.lfo2_dest) + } else { + (params.lfo_waveform, params.lfo_division, params.lfo_depth, params.lfo_dest) + }; + let col_width = (area.width as usize) / 4; // Row 0: values — [Exp] 1/4 ████░░░░ [FilterCutoff] @@ -535,8 +560,8 @@ fn render_lfo_row( let mut spans: Vec = Vec::new(); // Wave selector - let wave_sel = focused && selected == SynthControlField::LfoWaveform; - let wave_name = lfo_waveform_name(params.lfo_waveform); + let wave_sel = focused && selected == wave_field; + let wave_name = lfo_waveform_name(waveform); let wave_color = if wave_sel { theme::PINK } else { theme::AMBER }; let wave_str = format!("[{}]", wave_name); spans.push(Span::styled( @@ -545,8 +570,8 @@ fn render_lfo_row( )); // Division selector - let div_sel = focused && selected == SynthControlField::LfoDivision; - let div_name = lfo_division_name(params.lfo_division); + let div_sel = focused && selected == div_field; + let div_name = lfo_division_name(division); let div_color = if div_sel { theme::PINK } else { theme::AMBER }; spans.push(Span::styled( format!("{:^width$}", div_name, width = col_width), @@ -554,10 +579,10 @@ fn render_lfo_row( )); // Depth: horizontal bar - let depth_sel = focused && selected == SynthControlField::LfoDepth; + let depth_sel = focused && selected == depth_field; let depth_color = if depth_sel { theme::PINK } else { theme::AMBER }; let bar_width = col_width.saturating_sub(2); - let filled = (params.lfo_depth * bar_width as f32).round() as usize; + let filled = (depth * bar_width as f32).round() as usize; let empty = bar_width.saturating_sub(filled); let bar = format!("{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(empty)); spans.push(Span::raw(" ")); @@ -565,8 +590,8 @@ fn render_lfo_row( spans.push(Span::raw(" ".repeat(col_width.saturating_sub(bar_width + 1)))); // Dest selector - let dest_sel = focused && selected == SynthControlField::LfoDest; - let dest_name = lfo_dest_name(params.lfo_dest); + let dest_sel = focused && selected == dest_field; + let dest_name = lfo_dest_name(dest); let dest_color = if dest_sel { theme::PINK } else { theme::AMBER }; let dest_str = format!("[{}]", dest_name); spans.push(Span::styled( @@ -586,10 +611,10 @@ fn render_lfo_row( let mut spans: Vec = Vec::new(); for (i, label) in labels.iter().enumerate() { let is_sel = focused && match i { - 0 => selected == SynthControlField::LfoWaveform, - 1 => selected == SynthControlField::LfoDivision, - 2 => selected == SynthControlField::LfoDepth, - 3 => selected == SynthControlField::LfoDest, + 0 => selected == wave_field, + 1 => selected == div_field, + 2 => selected == depth_field, + 3 => selected == dest_field, _ => false, }; let style = if is_sel { From 3318ce36491acb7340d9e9ea5c59f18dc3a3cb0b Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 23:49:21 +0100 Subject: [PATCH 2/5] fix: stack LFO2 under LFO1 instead of side-by-side Layout now: AMP (left 50%) | LFO1 over LFO2 (right 50%, stacked). Updated both rendering and mouse hit-testing to match. Co-Authored-By: Claude Opus 4.6 --- src/mouse.rs | 72 ++++++++++++++++++++++++------------------- src/ui/synth_knobs.rs | 36 +++++++++++++--------- 2 files changed, 63 insertions(+), 45 deletions(-) diff --git a/src/mouse.rs b/src/mouse.rs index 092ac29..afbfecd 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -448,7 +448,7 @@ fn hit_test_synth_knobs(col: u16, row: u16, knobs_area: Rect) -> Option Option Option 0 { - let rel_x = (col - cols[1].x) as usize; - let idx = (rel_x / col_width).min(3); - return match idx { - 0 => Some(SynthControlField::LfoWaveform), - 1 => Some(SynthControlField::LfoDivision), - 2 => Some(SynthControlField::LfoDepth), - _ => Some(SynthControlField::LfoDest), - }; - } - return Some(SynthControlField::LfoWaveform); - } else if hit_test_area(col, row, cols[2]) { - // LFO2 - let col_width = cols[2].width as usize / 4; - if col_width > 0 { - let rel_x = (col - cols[2].x) as usize; - let idx = (rel_x / col_width).min(3); - return match idx { - 0 => Some(SynthControlField::Lfo2Waveform), - 1 => Some(SynthControlField::Lfo2Division), - 2 => Some(SynthControlField::Lfo2Depth), - _ => Some(SynthControlField::Lfo2Dest), - }; + // LFO1 + LFO2 stacked + let lfo_rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // LFO1 + Constraint::Min(3), // LFO2 + ]) + .split(cols[1]); + + if hit_test_area(col, row, lfo_rows[0]) { + // LFO1 + let col_width = lfo_rows[0].width as usize / 4; + if col_width > 0 { + let rel_x = (col - lfo_rows[0].x) as usize; + let idx = (rel_x / col_width).min(3); + return match idx { + 0 => Some(SynthControlField::LfoWaveform), + 1 => Some(SynthControlField::LfoDivision), + 2 => Some(SynthControlField::LfoDepth), + _ => Some(SynthControlField::LfoDest), + }; + } + return Some(SynthControlField::LfoWaveform); + } else if hit_test_area(col, row, lfo_rows[1]) { + // LFO2 + let col_width = lfo_rows[1].width as usize / 4; + if col_width > 0 { + let rel_x = (col - lfo_rows[1].x) as usize; + let idx = (rel_x / col_width).min(3); + return match idx { + 0 => Some(SynthControlField::Lfo2Waveform), + 1 => Some(SynthControlField::Lfo2Division), + 2 => Some(SynthControlField::Lfo2Depth), + _ => Some(SynthControlField::Lfo2Dest), + }; + } + return Some(SynthControlField::Lfo2Waveform); } - return Some(SynthControlField::Lfo2Waveform); } } diff --git a/src/ui/synth_knobs.rs b/src/ui/synth_knobs.rs index 83ea836..04094c2 100644 --- a/src/ui/synth_knobs.rs +++ b/src/ui/synth_knobs.rs @@ -203,14 +203,13 @@ pub fn render_synth_knobs(f: &mut Frame, area: Rect, app: &App, synth_id: SynthI render_adsr_bars(f, filt_split[1], params, FILT_ENV_ADSR, sel, focused); } - // ── Row group 3: AMP (left) + LFO1 (center) + LFO2 (right) ──────── + // ── Row group 3: AMP (left) + LFO1/LFO2 stacked (right) ────────── { let cols = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Percentage(34), // AMP - Constraint::Percentage(33), // LFO1 - Constraint::Percentage(33), // LFO2 + Constraint::Percentage(50), // AMP + Constraint::Percentage(50), // LFO1 + LFO2 stacked ]) .split(row_groups[2]); @@ -224,23 +223,32 @@ pub fn render_synth_knobs(f: &mut Frame, area: Rect, app: &App, synth_id: SynthI ); render_amp_group(f, amp_body, params, app.effect_params.synth_saturator_drive, sel, focused); + // LFO1 + LFO2 stacked vertically in the right column + let lfo_rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // LFO1: header + value + label + Constraint::Min(3), // LFO2: header + value + label + ]) + .split(cols[1]); + // LFO1 section - render_section_header(f, cols[1], "LFO1"); + render_section_header(f, lfo_rows[0], "LFO1"); let lfo1_body = Rect::new( - cols[1].x, - cols[1].y + 1, - cols[1].width, - cols[1].height.saturating_sub(1), + lfo_rows[0].x, + lfo_rows[0].y + 1, + lfo_rows[0].width, + lfo_rows[0].height.saturating_sub(1), ); render_lfo_row(f, lfo1_body, params, sel, focused, false); // LFO2 section - render_section_header(f, cols[2], "LFO2"); + render_section_header(f, lfo_rows[1], "LFO2"); let lfo2_body = Rect::new( - cols[2].x, - cols[2].y + 1, - cols[2].width, - cols[2].height.saturating_sub(1), + lfo_rows[1].x, + lfo_rows[1].y + 1, + lfo_rows[1].width, + lfo_rows[1].height.saturating_sub(1), ); render_lfo_row(f, lfo2_body, params, sel, focused, true); } From ee735b9fdbd399e9e6f27fb9e84323b79638c301 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 23:50:27 +0100 Subject: [PATCH 3/5] fix: use unique display names for LFO destination targets Names now distinguish Osc1 vs Osc2 params: O1Tune, O2Tune, O1PWM, etc. Previously both showed as "Tune", "PWM", "Level" making it look like only a few targets were available. Co-Authored-By: Claude Opus 4.6 --- src/sequencer/synth_pattern.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/sequencer/synth_pattern.rs b/src/sequencer/synth_pattern.rs index 62c33fe..459aaa7 100644 --- a/src/sequencer/synth_pattern.rs +++ b/src/sequencer/synth_pattern.rs @@ -599,10 +599,25 @@ pub fn lfo_division_name(v: f32) -> &'static str { LFO_DIVISIONS[lfo_division_index(v)].1 } +/// Unique display names for LFO destination targets. +const LFO_DEST_NAMES: [&str; 11] = [ + "Freq", // FilterCutoff + "Res", // FilterResonance + "O1Tune", // Osc1Tune + "O1PWM", // Osc1Pwm + "O1Lvl", // Osc1Level + "O2Tune", // Osc2Tune + "O2PWM", // Osc2Pwm + "O2Lvl", // Osc2Level + "O2Det", // Osc2Detune + "Sub", // SubLevel + "Vol", // Volume +]; + /// Get display name for an LFO destination index. pub fn lfo_dest_name(idx: u8) -> &'static str { - if (idx as usize) < LFO_DEST_FIELDS.len() { - LFO_DEST_FIELDS[idx as usize].full_label() + if (idx as usize) < LFO_DEST_NAMES.len() { + LFO_DEST_NAMES[idx as usize] } else { "???" } From f399c4e0a5ab20bdad987e866f271bccc8ed8012 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 23:53:46 +0100 Subject: [PATCH 4/5] fix: mouse click on LFO enum fields now cycles all values The mouse click handler for enum fields was using max_val=3 for all non-FilterType fields, causing LFO dest to only cycle through 4 of 11 targets and LFO waveform through 4 of 6. Now uses correct max for each: - LFO waveform: 5 (6 waveforms) - LFO division: 17 (18 beat divisions) - LFO dest: 10 (11 targets) Co-Authored-By: Claude Opus 4.6 --- src/mouse.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/mouse.rs b/src/mouse.rs index afbfecd..abe7353 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -639,7 +639,16 @@ fn handle_synth_knobs_click(app: &mut App, synth_id: SynthId, field: SynthContro // For enum fields, just cycle on click instead of drag if field.is_enum() { - let max_val: u8 = if field == SynthControlField::FilterType { 2 } else { 3 }; + let max_val: u8 = match field { + SynthControlField::FilterType => 2, + SynthControlField::LfoWaveform | SynthControlField::Lfo2Waveform => + (crate::sequencer::synth_pattern::NUM_LFO_WAVEFORMS - 1), + SynthControlField::LfoDivision | SynthControlField::Lfo2Division => + (crate::sequencer::synth_pattern::LFO_DIVISIONS.len() - 1) as u8, + SynthControlField::LfoDest | SynthControlField::Lfo2Dest => + (crate::sequencer::synth_pattern::LFO_DEST_FIELDS.len() - 1) as u8, + _ => 3, // Osc1/Osc2 waveforms + }; let cur = field.get(&pattern.params); let cur_int = (cur * max_val as f32).round() as u8; let new_int = (cur_int + 1) % (max_val + 1); From 44e9004d231fe63b893b4aeb72beea7e542260a0 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 23:57:51 +0100 Subject: [PATCH 5/5] chore: bump version to 1.1.0 for LFO2 feature Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 4dee4b6..f5639ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "textstep" -version = "1.0.0" +version = "1.1.0" edition = "2024" [dependencies]