Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "textstep"
version = "1.0.0"
version = "1.1.0"
edition = "2024"

[dependencies]
Expand Down
34 changes: 34 additions & 0 deletions src/audio/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ pub struct SynthInstance {
pub gate_samples: u32,
pub note_end_step: Option<usize>,
pub lfo: Lfo,
pub lfo2: Lfo,
pub saturator: TubeSaturator,
pub reverb: ReverbEffect,
pub delay: DelayEffect,
Expand All @@ -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(),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 10 additions & 8 deletions src/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
66 changes: 50 additions & 16 deletions src/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ fn hit_test_synth_knobs(col: u16, row: u16, knobs_area: Rect) -> Option<SynthCon
.constraints([
Constraint::Length(8), // OSC1 + OSC2
Constraint::Length(8), // ENV1 + ENV2 + FILT
Constraint::Min(7), // AMP (left) + LFO (right)
Constraint::Min(7), // AMP (left) + LFO1/LFO2 stacked (right)
])
.split(inner);

Expand Down Expand Up @@ -526,13 +526,13 @@ fn hit_test_synth_knobs(col: u16, row: u16, knobs_area: Rect) -> Option<SynthCon
return None;
}

// ── Row group 2: AMP (left) + LFO (right) ─────────────────────────────
// ── Row group 2: AMP (left) + LFO1/LFO2 stacked (right) ──────────
if hit_test_area(col, row, row_groups[2]) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50), // AMP
Constraint::Percentage(50), // LFO
Constraint::Percentage(50), // LFO1 + LFO2 stacked
])
.split(row_groups[2]);

Expand All @@ -559,19 +559,44 @@ fn hit_test_synth_knobs(col: u16, row: u16, knobs_area: Rect) -> Option<SynthCon
}
return Some(SynthControlField::Volume);
} else if hit_test_area(col, row, cols[1]) {
// LFO
let col_width = cols[1].width as usize / 4;
if col_width > 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),
};
// 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::LfoWaveform);
}
}

Expand Down Expand Up @@ -614,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);
Expand Down
1 change: 1 addition & 0 deletions src/presets/synth_presets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
74 changes: 67 additions & 7 deletions src/sequencer/synth_pattern.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)]
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -247,11 +266,16 @@ pub enum SynthControlField {
FilterEnvDecay,
FilterEnvSustain,
FilterEnvRelease,
// LFO
// LFO1
LfoWaveform,
LfoDivision,
LfoDepth,
LfoDest,
// LFO2
Lfo2Waveform,
Lfo2Division,
Lfo2Depth,
Lfo2Dest,
// Output
Volume,
SendReverb,
Expand All @@ -260,7 +284,7 @@ pub enum SynthControlField {
Mute,
}

static ALL_FIELDS: [SynthControlField; 34] = [
static ALL_FIELDS: [SynthControlField; 38] = [
SynthControlField::Osc1Waveform,
SynthControlField::Osc1Tune,
SynthControlField::Osc1Pwm,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -554,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 {
"???"
}
Expand Down
Loading
Loading