From 8206f554e0ecffe3ed964b2eec698938b50ceb64 Mon Sep 17 00:00:00 2001 From: lobo Date: Tue, 17 Mar 2026 23:04:26 +0100 Subject: [PATCH] feat: improved kick voice, synth engine features, CPU optimizations, bump v2.0.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kick voice improvements: - Dual-stage pitch envelope (fast 2.5ms attack + color-controlled settle) - BP/LP click blend for 909 bridged-T "knock" character - Subharmonic at 0.5× (octave below) replacing 0.75× fourth - Asymmetric drive saturation for even-harmonic analog warmth - 0.5ms attack ramp to prevent DC click artifact - Drive-dependent 2nd harmonic (10-45% based on drive) - Retuned all kick presets and defaults for new voice Synth engine features: - Portamento/glide with exponential pitch smoothing (5-500ms) - Filter key follow (note pitch modulates cutoff relative to C4) - Sub oscillator waveform choice (Square/Sine/Saw) - Hard osc sync (osc2 phase reset on osc1 cycle) - 4-voice supersaw with wider spread (up to 50 cents) - UI controls: Glide slider, [X]Sync toggle, Sub:[] selector, KeyFl slider CPU optimizations (no quality loss): - Cached SVF filter g/k coefficients (skip tan() when unchanged) - Fast exp2 polynomial for pitch ratios (replace per-sample powf) - Incremental peak tracking in lookahead limiter (amortized O(1)) - Skip inactive oscillators (osc1/osc2/sub when level=0 or env idle) Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/audio/drum_voice.rs | 112 ++++++++++++------ src/audio/effects.rs | 35 +++++- src/audio/synth_voice.rs | 210 +++++++++++++++++++++++++-------- src/keys.rs | 8 +- src/mouse.rs | 18 ++- src/presets/drum_presets.rs | 50 ++++---- src/presets/synth_presets.rs | 32 +++++ src/sequencer/drum_pattern.rs | 4 +- src/sequencer/synth_pattern.rs | 43 ++++++- src/ui/synth_knobs.rs | 73 +++++++++++- 12 files changed, 466 insertions(+), 123 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a30f3b..060d8e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1634,7 +1634,7 @@ dependencies = [ [[package]] name = "textstep" -version = "2.0.0" +version = "2.0.1" dependencies = [ "cpal", "crossbeam-channel", diff --git a/Cargo.toml b/Cargo.toml index e1cb804..33bbbbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "textstep" -version = "2.0.1" +version = "2.0.3" edition = "2024" [dependencies] diff --git a/src/audio/drum_voice.rs b/src/audio/drum_voice.rs index 7e1826e..4594b85 100644 --- a/src/audio/drum_voice.rs +++ b/src/audio/drum_voice.rs @@ -13,12 +13,20 @@ pub trait DrumVoiceDsp: Send { /// Soft waveshaping/saturation driven by the drive parameter. /// drive=0 is clean, drive=1 is heavy saturation. +/// Asymmetric clipping: positive side clips harder than negative, +/// producing even harmonics (2nd, 4th) for analog warmth. fn apply_drive(x: f32, drive: f32) -> f32 { if drive < 0.001 { return x; } let gain = 1.0 + drive * 8.0; - (x * gain).tanh() / gain.tanh() + let s = x * gain; + let norm = gain.tanh(); + if s >= 0.0 { + s.tanh() / norm + } else { + (s * 0.8).tanh() * 1.25 / norm + } } // --------------------------------------------------------------------------- @@ -226,42 +234,49 @@ impl CombFilter { // =================================================================== // 1. KickVoice (TR-909 inspired: sine osc + pitch env + click impulse) // -// Two signal paths summed: -// Path A: Sine oscillator → pitch envelope → body amp envelope → LP filter -// Path B: Short impulse (DC pulse) → resonant LP filter (~5kHz) → click amp envelope +// Three signal paths summed: +// Path A: Sine oscillator → dual-stage pitch envelope → body amp envelope → LP filter +// Path B: Short impulse (DC pulse) → resonant BP/LP filter (~5kHz) → click amp envelope +// Path C: Subharmonic (one octave below) for low-end weight // // Parameters: // tune — fundamental frequency (30-80 Hz) // sweep — pitch envelope depth (how far above fundamental the pitch starts) -// color — pitch envelope decay time (fast thump vs slow "zoop") +// color — pitch envelope stage-2 decay time (fast thump vs slow "zoop") // snap — click/impulse level (the "Attack" knob) // filter — body LP cutoff (500-8000 Hz) -// drive — saturation +// drive — asymmetric saturation // decay — body amplitude decay (80-600ms) // =================================================================== pub struct KickVoice { sr: f64, - // Path A: sine oscillator with pitch envelope + // Path A: sine oscillator with dual-stage pitch envelope phase: f64, - freq_base: f32, // fundamental (tune) - freq_start: f32, // initial pitch (fundamental + sweep) - pitch_env: f32, // 1→0 pitch envelope - pitch_decay: f32, // per-sample pitch env decay coefficient - body_env: f32, // body amplitude envelope - body_decay: f32, // per-sample body env decay + freq_base: f32, // fundamental (tune) + freq_start: f32, // initial pitch (fundamental + sweep) + pitch_env: f32, // 1→0 pitch envelope + pitch_decay: f32, // per-sample pitch env decay coefficient (current stage) + pitch_decay_fast: f32, // stage-1 coefficient (~2-3ms, always fast) + pitch_decay_slow: f32, // stage-2 coefficient (controlled by color) + pitch_stage2: bool, // true once stage-1 envelope drops below threshold + body_env: f32, // body amplitude envelope + body_decay: f32, // per-sample body env decay // Body LP filter body_lp: OnePoleLP, - // Path B: click impulse → resonant LP filter - click_env: f32, // click amplitude envelope (fixed short decay) + // Attack ramp: ~0.5ms linear rise to avoid DC click at onset + sample_count: u32, + attack_samples: u32, + // Path B: click impulse → resonant BP/LP filter + click_env: f32, // click amplitude envelope (fixed short decay) click_decay: f32, - click_level: f32, // overall click amplitude (snap) + click_level: f32, // overall click amplitude (snap) click_pulse_phase: f64, // low-freq square wave for impulse - click_svf: StateVariableFilter, // resonant LP at ~5kHz + click_svf: StateVariableFilter, // resonant filter at ~5kHz // noise: Noise, drive: f32, active: bool, - // Sub-oscillator: one octave below for low-end weight + // Subharmonic: one octave below (0.5×) for phase-coherent low-end sub_phase: f64, sub_freq: f32, sub_env: f32, @@ -277,9 +292,14 @@ impl KickVoice { freq_start: 200.0, pitch_env: 0.0, pitch_decay: 0.0, + pitch_decay_fast: 0.0, + pitch_decay_slow: 0.0, + pitch_stage2: false, body_env: 0.0, body_decay: 0.0, body_lp: OnePoleLP::new(), + sample_count: 0, + attack_samples: (sr * 0.0005) as u32, // ~0.5ms click_env: 0.0, click_decay: 0.0, click_level: 0.5, @@ -300,6 +320,7 @@ impl DrumVoiceDsp for KickVoice { fn trigger(&mut self, p: &DrumTrackParams) { self.phase = 0.0; self.click_pulse_phase = 0.0; + self.sample_count = 0; // ── Path A: sine body ──────────────────────────────────────── @@ -309,12 +330,15 @@ impl DrumVoiceDsp for KickVoice { // sweep: pitch envelope depth (0-300 Hz above fundamental) self.freq_start = self.freq_base + p.sweep * 300.0; - // Pitch envelope: always starts at 1.0, decays to 0 → pitch settles to fundamental + // Dual-stage pitch envelope: + // Stage 1: always fast (~2-3ms) — the initial transient "click" pitch + // Stage 2: controlled by color — the body tone forming self.pitch_env = 1.0; - // color: pitch envelope decay time (5-80ms) - // Low color = fast snap (punchy 909), high color = slow pitch glide - let pitch_time = 0.005 + p.color as f64 * 0.075; - self.pitch_decay = (-5.0_f64 / (pitch_time * self.sr)).exp() as f32; + self.pitch_stage2 = false; + self.pitch_decay_fast = (-5.0_f64 / (0.0025 * self.sr)).exp() as f32; + let pitch_time_slow = 0.010 + p.color as f64 * 0.070; + self.pitch_decay_slow = (-5.0_f64 / (pitch_time_slow * self.sr)).exp() as f32; + self.pitch_decay = self.pitch_decay_fast; // start in stage 1 // decay: body amplitude envelope (80-600ms) let body_time = 0.08 + p.decay as f64 * 0.52; @@ -335,15 +359,17 @@ impl DrumVoiceDsp for KickVoice { self.click_env = 1.0; self.click_decay = (-5.0_f64 / (0.004 * self.sr)).exp() as f32; - // Resonant LP filter on click (~5kHz, resonance ~18%) + // Resonant BP/LP filter on click (~5kHz, resonance ~18%) + // Using bandpass blend for the sharper "knock" of a 909 bridged-T network let click_filter_freq = 4000.0 + p.snap * 2000.0; self.click_svf.set_freq(click_filter_freq, 0.18, self.sr); self.click_svf.reset(); - // Sub-oscillator: a fourth below the body fundamental (~41 Hz at default) - self.sub_freq = self.freq_base * 0.75; + // ── Path C: subharmonic (one octave below for phase-coherent low-end) ── + + self.sub_freq = self.freq_base * 0.5; // octave below, not a fourth self.sub_phase = 0.0; - self.sub_env = 0.35; // subtle reinforcement, not overwhelming + self.sub_env = 0.25; // subtle reinforcement // Decay tracks body but slightly shorter — support without boom self.sub_decay = (-5.0_f64 / ((0.10 + p.decay as f64 * 0.3) * self.sr)).exp() as f32; @@ -356,10 +382,18 @@ impl DrumVoiceDsp for KickVoice { return 0.0; } + self.sample_count += 1; + // ── Path A: pitched sine body ── - // Pitch envelope: cubed for fast initial drop then slow settle + // Dual-stage pitch envelope: + // Stage 1 (fast): cubed envelope for sharp transient pitch drop + // Stage 2 (slow): switches once envelope drops below 0.4 for body tone forming let pe = self.pitch_env; + if !self.pitch_stage2 && pe < 0.4 { + self.pitch_stage2 = true; + self.pitch_decay = self.pitch_decay_slow; + } let pitch_shaped = pe * pe * pe; let freq = self.freq_base + (self.freq_start - self.freq_base) * pitch_shaped; self.pitch_env *= self.pitch_decay; @@ -370,11 +404,18 @@ impl DrumVoiceDsp for KickVoice { self.phase -= 1.0; } let sine = (self.phase * std::f64::consts::TAU).sin() as f32; - // 2nd harmonic for mid-range presence on smaller speakers - let harmonic2 = (self.phase * 2.0 * std::f64::consts::TAU).sin() as f32 * 0.25; + // 2nd harmonic: amount scales with drive (10-45%) for analog-like behavior + let harm_amount = 0.1 + self.drive * 0.35; + let harmonic2 = (self.phase * 2.0 * std::f64::consts::TAU).sin() as f32 * harm_amount; // Body LP filter + envelope - let body = self.body_lp.tick(sine + harmonic2) * self.body_env; + // Attack ramp: ~0.5ms linear rise to avoid DC click artifact at onset + let attack_amp = if self.sample_count < self.attack_samples { + self.sample_count as f32 / self.attack_samples as f32 + } else { + 1.0 + }; + let body = self.body_lp.tick(sine + harmonic2) * self.body_env * attack_amp; self.body_env *= self.body_decay; // ── Path B: click impulse ── @@ -387,12 +428,15 @@ impl DrumVoiceDsp for KickVoice { // Add a tiny bit of noise for texture let click_raw = impulse + self.noise.next() * 0.15; - // Resonant LP filter gives the click its "knock" character + // Resonant BP/LP blend for 909 bridged-T "knock" character + // BP gives the sharp resonant peak, LP fills out the body self.click_svf.tick(click_raw); - let click = self.click_svf.lp() * self.click_env * self.click_level; + let click = (self.click_svf.bp() * 0.7 + self.click_svf.lp() * 0.3) + * self.click_env + * self.click_level; self.click_env *= self.click_decay; - // ── Path C: sub-oscillator (a fourth below for chest-hitting low-end) ── + // ── Path C: subharmonic (one octave below for phase-coherent low-end) ── self.sub_phase += self.sub_freq as f64 / self.sr; if self.sub_phase >= 1.0 { diff --git a/src/audio/effects.rs b/src/audio/effects.rs index e00f9b8..56ee5b6 100644 --- a/src/audio/effects.rs +++ b/src/audio/effects.rs @@ -839,12 +839,15 @@ impl Oversampler2x { pub struct LookaheadLimiter { buf_l: Vec, buf_r: Vec, + abs_buf: Vec, // cached max(|L|, |R|) per position for O(1) peak tracking pos: usize, len: usize, threshold: f32, gain: f32, attack_coeff: f32, release_coeff: f32, + running_peak: f32, // current known peak in the buffer + samples_since_scan: u32, // counts down; triggers full rescan when the peak sample exits } impl LookaheadLimiter { @@ -860,24 +863,43 @@ impl LookaheadLimiter { Self { buf_l: vec![0.0; len], buf_r: vec![0.0; len], + abs_buf: vec![0.0; len], pos: 0, len, threshold: 0.95, gain: 1.0, attack_coeff, release_coeff, + running_peak: 0.0, + samples_since_scan: 0, } } /// Process one stereo frame. Returns limited (l, r). pub fn tick_stereo(&mut self, in_l: f32, in_r: f32) -> (f32, f32) { - // Find peak in lookahead window (scan full buffer) - let mut peak = 0.0_f32; - for i in 0..self.len { - peak = peak.max(self.buf_l[i].abs()).max(self.buf_r[i].abs()); + let new_abs = in_l.abs().max(in_r.abs()); + + // Check if the sample being overwritten was the peak + let outgoing_abs = self.abs_buf[self.pos]; + let needs_rescan = outgoing_abs >= self.running_peak - 1e-10; + + if new_abs >= self.running_peak { + // New sample is the new peak — no scan needed + self.running_peak = new_abs; + } else if needs_rescan { + // The outgoing sample was the peak; find the new peak via full scan + // This only happens when the loudest sample exits the window + let mut peak = new_abs; + for i in 0..self.len { + if i != self.pos { + peak = peak.max(self.abs_buf[i]); + } + } + self.running_peak = peak; } - // Also consider incoming samples - peak = peak.max(in_l.abs()).max(in_r.abs()); + // else: running_peak is still valid (outgoing wasn't the peak, new isn't higher) + + let peak = self.running_peak; // Compute target gain let target_gain = if peak > self.threshold { @@ -900,6 +922,7 @@ impl LookaheadLimiter { // Write new input self.buf_l[self.pos] = in_l; self.buf_r[self.pos] = in_r; + self.abs_buf[self.pos] = new_abs; self.pos = (self.pos + 1) % self.len; (out_l, out_r) diff --git a/src/audio/synth_voice.rs b/src/audio/synth_voice.rs index d819037..bc35191 100644 --- a/src/audio/synth_voice.rs +++ b/src/audio/synth_voice.rs @@ -82,6 +82,39 @@ impl OnePoleLP { } } +// --------------------------------------------------------------------------- +// Fast pitch ratio approximations (avoid per-sample powf) +// --------------------------------------------------------------------------- + +/// Fast 2^(semitones/12) using a 4th-order polynomial approximation of exp2. +/// Max error ~0.01% across ±24 semitones — inaudible. +#[inline] +fn fast_semitone_ratio(semitones: f32) -> f32 { + fast_exp2(semitones * (1.0 / 12.0)) +} + +/// Fast 2^(cents/1200) for fine detuning. +#[inline] +fn fast_cent_ratio(cents: f32) -> f32 { + fast_exp2(cents * (1.0 / 1200.0)) +} + +/// Fast exp2(x) approximation using polynomial: 2^x for x in roughly [-2, 2]. +/// Uses Remez-style 4th-order polynomial on the fractional part. +#[inline] +fn fast_exp2(x: f32) -> f32 { + // Split into integer and fractional parts + let xi = x.floor(); + let xf = x - xi; + // Polynomial approx of 2^xf for xf in [0, 1) + // Coefficients from minimax fit + let p = 1.0 + xf * (0.6931472 + xf * (0.2402265 + xf * (0.0554905 + xf * 0.0096860))); + // Multiply by 2^integer_part via bit manipulation + let int_part = (xi as i32 + 127) as u32; + let pow2_int = f32::from_bits(int_part << 23); + p * pow2_int +} + // --------------------------------------------------------------------------- // PolyBLEP anti-aliasing correction for discontinuities // --------------------------------------------------------------------------- @@ -108,6 +141,8 @@ fn poly_blep(t: f64, dt: f64) -> f64 { pub struct Oscillator { phase: f32, phase2: f32, // second phase for supersaw detune + phase3: f32, // third phase for wider supersaw + phase4: f32, // fourth phase for wider supersaw sample_rate: f32, noise_lp: OnePoleLP, } @@ -117,6 +152,8 @@ impl Oscillator { Self { phase: 0.0, phase2: 0.0, + phase3: 0.33, // staggered initial phases for supersaw richness + phase4: 0.67, sample_rate, noise_lp: OnePoleLP::new(), } @@ -159,16 +196,28 @@ impl Oscillator { if param < 0.001 { saw1 } else { - // Supersaw: blend with a detuned second saw (also PolyBLEP'd) - let detune = param * 0.02; // up to ~1 semitone - let inc2 = freq_hz * (1.0 + detune) / self.sample_rate; + // Supersaw: 4 detuned saws with configurable spread (0-50 cents) + let spread = param * 0.04; // up to ~50 cents total spread + + let inc2 = freq_hz * (1.0 + spread) / self.sample_rate; let mut saw2 = 2.0 * self.phase2 - 1.0; saw2 -= poly_blep(self.phase2 as f64, inc2 as f64) as f32; self.phase2 += inc2; - if self.phase2 >= 1.0 { - self.phase2 -= 1.0; - } - (saw1 + saw2) * 0.5 + if self.phase2 >= 1.0 { self.phase2 -= 1.0; } + + let inc3 = freq_hz * (1.0 - spread * 0.7) / self.sample_rate; + let mut saw3 = 2.0 * self.phase3 - 1.0; + saw3 -= poly_blep(self.phase3 as f64, inc3 as f64) as f32; + self.phase3 += inc3; + if self.phase3 >= 1.0 { self.phase3 -= 1.0; } + + let inc4 = freq_hz * (1.0 + spread * 0.5) / self.sample_rate; + let mut saw4 = 2.0 * self.phase4 - 1.0; + saw4 -= poly_blep(self.phase4 as f64, inc4 as f64) as f32; + self.phase4 += inc4; + if self.phase4 >= 1.0 { self.phase4 -= 1.0; } + + (saw1 + saw2 + saw3 + saw4) * 0.25 } } Waveform::Sine => { @@ -338,14 +387,24 @@ pub struct Filter24dB { svf1: Svf, svf2: Svf, sample_rate: f32, + // Cached coefficients — recomputed only when cutoff/resonance changes + cached_cutoff: f32, + cached_reso: f32, + cached_g: f32, + cached_k: f32, } impl Filter24dB { pub fn new(sample_rate: f32) -> Self { + let default_g = (std::f32::consts::PI * 1000.0 / sample_rate).tan(); Self { svf1: Svf::new(), svf2: Svf::new(), sample_rate, + cached_cutoff: 1000.0, + cached_reso: 0.0, + cached_g: default_g, + cached_k: 2.0, } } @@ -354,8 +413,16 @@ impl Filter24dB { let cutoff = cutoff_hz.clamp(5.0, 20000.0); let reso = resonance.clamp(0.0, 0.99); - let g = (std::f32::consts::PI * cutoff / self.sample_rate).tan(); - let k = 2.0 - 2.0 * reso; + // Only recompute tan() when cutoff or resonance actually changed + if (cutoff - self.cached_cutoff).abs() > 0.01 || (reso - self.cached_reso).abs() > 0.0001 { + self.cached_g = (std::f32::consts::PI * cutoff / self.sample_rate).tan(); + self.cached_k = 2.0 - 2.0 * reso; + self.cached_cutoff = cutoff; + self.cached_reso = reso; + } + + let g = self.cached_g; + let k = self.cached_k; let (lp1, hp1, bp1) = self.svf1.tick(input, g, k); let stage1 = match filter_type { @@ -375,9 +442,10 @@ impl Filter24dB { /// Paraphonic synthesizer voice with dual oscillators, sub oscillator, /// two ADSR envelopes (amplitude + filter), and a state-variable filter. pub struct SynthVoice { + sample_rate: f32, osc1: Oscillator, osc2: Oscillator, - sub_osc: Oscillator, // always square, 1 oct below osc2 + sub_osc: Oscillator, // 1 oct below osc2, configurable waveform noise1: Noise, noise2: Noise, noise_sub: Noise, @@ -385,7 +453,9 @@ pub struct SynthVoice { env2: AdsrEnvelope, // osc2 + sub amplitude filter_env: AdsrEnvelope, // filter modulation filter: Filter24dB, - note_freq: f32, // base frequency from MIDI note + note_freq: f32, // target frequency from MIDI note + current_freq: f32, // portamento: smoothed frequency (approaches note_freq) + glide_coeff: f32, // per-sample portamento coefficient (0=instant, ~1=slow) } // SynthVoice is Send because all fields are plain data (no Rc, no raw pointers). @@ -394,6 +464,7 @@ unsafe impl Send for SynthVoice {} impl SynthVoice { pub fn new(sample_rate: f32) -> Self { Self { + sample_rate, osc1: Oscillator::new(sample_rate), osc2: Oscillator::new(sample_rate), sub_osc: Oscillator::new(sample_rate), @@ -405,13 +476,27 @@ impl SynthVoice { filter_env: AdsrEnvelope::new(sample_rate), filter: Filter24dB::new(sample_rate), note_freq: 440.0, + current_freq: 440.0, + glide_coeff: 0.0, } } /// Trigger the voice with given params and MIDI note number. pub fn trigger(&mut self, params: &SynthParams, note: u8) { // Convert MIDI note to frequency - self.note_freq = 440.0 * 2.0_f32.powf((note as f32 - 69.0) / 12.0); + let new_freq = 440.0 * 2.0_f32.powf((note as f32 - 69.0) / 12.0); + self.note_freq = new_freq; + + // Portamento: if glide > 0, smoothly slide from current pitch + if params.glide > 0.001 { + // Glide time: 5ms (fast) to 500ms (slow), exponential mapping + let glide_time = 0.005 * (100.0_f32).powf(params.glide); + self.glide_coeff = (-5.0 / (glide_time * self.sample_rate)).exp(); + // current_freq keeps its value from the previous note + } else { + self.glide_coeff = 0.0; + self.current_freq = new_freq; // jump immediately + } // Set envelope parameters self.env1.set_params( @@ -454,45 +539,69 @@ impl SynthVoice { /// Generate one sample of audio output. pub fn tick(&mut self, params: &SynthParams) -> f32 { - // --- Oscillator frequencies --- - // osc1_tune: 0-1 mapped to -24..+24 semitones - let osc1_tune_semitones = params.osc1_tune * 48.0 - 24.0; - let freq1 = self.note_freq * 2.0_f32.powf(osc1_tune_semitones / 12.0); - - // osc2_tune: 0-1 mapped to -24..+24 semitones - let osc2_tune_semitones = params.osc2_tune * 48.0 - 24.0; - // osc2_detune: 0-1 mapped to -50..+50 cents - let detune_cents = params.osc2_detune * 100.0 - 50.0; - let freq2 = self.note_freq - * 2.0_f32.powf(osc2_tune_semitones / 12.0) - * 2.0_f32.powf(detune_cents / 1200.0); - - // Sub oscillator: 1 octave below osc2 - let sub_freq = freq2 * 0.5; - - // --- Waveform selection --- - let wf1 = Waveform::from_u8(params.osc1_waveform); - let wf2 = Waveform::from_u8(params.osc2_waveform); - - // --- Envelope ticks --- + // --- Portamento: smooth frequency transition --- + if self.glide_coeff > 0.0001 { + self.current_freq = self.note_freq + + (self.current_freq - self.note_freq) * self.glide_coeff; + } else { + self.current_freq = self.note_freq; + } + let base_freq = self.current_freq; + + // --- Envelope ticks (always advance, even if osc is silent) --- let env1_val = self.env1.tick(); let env2_val = self.env2.tick(); let filter_env_val = self.filter_env.tick(); - // --- Oscillator generation --- - let osc1_out = self.osc1.tick(freq1, wf1, params.osc1_pwm, &mut self.noise1) - * env1_val - * params.osc1_level; + // --- Osc1: skip if level is zero --- + let osc1_out = if params.osc1_level > 0.001 && env1_val > 0.0001 { + let osc1_tune_semitones = params.osc1_tune * 48.0 - 24.0; + let freq1 = base_freq * fast_semitone_ratio(osc1_tune_semitones); + self.osc1.tick(freq1, Waveform::from_u8(params.osc1_waveform), params.osc1_pwm, &mut self.noise1) + * env1_val + * params.osc1_level + } else { + 0.0 + }; - let osc2_out = self.osc2.tick(freq2, wf2, params.osc2_pwm, &mut self.noise2) - * env2_val - * params.osc2_level; + // --- Osc2 + Sub: skip if both levels are zero --- + let osc2_active = params.osc2_level > 0.001 && env2_val > 0.0001; + let (osc2_out, sub_out) = if osc2_active { + let osc2_tune_semitones = params.osc2_tune * 48.0 - 24.0; + let detune_cents = params.osc2_detune * 100.0 - 50.0; + let freq2 = base_freq + * fast_semitone_ratio(osc2_tune_semitones) + * fast_cent_ratio(detune_cents); + + // Osc sync: reset osc2 phase when osc1 completes a cycle + if params.osc_sync > 0 { + let freq1 = base_freq * fast_semitone_ratio(params.osc1_tune * 48.0 - 24.0); + if self.osc1.phase < (freq1 / self.sample_rate) { + self.osc2.phase = 0.0; + } + } - // Sub oscillator: always square, pw=0.5, sub of osc2 (scales with osc2_level) - let sub_out = self.sub_osc.tick(sub_freq, Waveform::Square, 0.5, &mut self.noise_sub) - * env2_val - * params.osc2_level - * params.sub_level; + let o2 = self.osc2.tick(freq2, Waveform::from_u8(params.osc2_waveform), params.osc2_pwm, &mut self.noise2) + * env2_val + * params.osc2_level; + + let sub = if params.sub_level > 0.001 { + let sub_wf = match params.sub_waveform { + 1 => Waveform::Sine, + 2 => Waveform::Saw, + _ => Waveform::Square, + }; + self.sub_osc.tick(freq2 * 0.5, sub_wf, 0.5, &mut self.noise_sub) + * env2_val + * params.osc2_level + * params.sub_level + } else { + 0.0 + }; + (o2, sub) + } else { + (0.0, 0.0) + }; // --- Mix --- let mix = osc1_out + osc2_out + sub_out; @@ -500,7 +609,16 @@ impl SynthVoice { // --- Filter --- // filter_cutoff 0-1 mapped to 5..20000 Hz (exponential) let base_cutoff = 5.0 * (4000.0_f32).powf(params.filter_cutoff); - let cutoff_mod = (base_cutoff + filter_env_val * params.filter_env_amount * 10000.0).max(5.0); + // Key follow: add fraction of note frequency to cutoff (0=off, 1=full tracking) + let key_follow_offset = if params.filter_key_follow > 0.001 { + (base_freq - 261.6) * params.filter_key_follow * 2.0 + } else { + 0.0 + }; + let cutoff_mod = (base_cutoff + + filter_env_val * params.filter_env_amount * 10000.0 + + key_follow_offset) + .clamp(5.0, 20000.0); let output = self.filter.tick(mix, cutoff_mod, params.filter_resonance, params.filter_type); output * params.volume diff --git a/src/keys.rs b/src/keys.rs index 775bf44..366aa30 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -1003,14 +1003,14 @@ fn handle_synth_grid(app: &mut App, key: KeyEvent, synth_id: SynthId) { const SYNTH_CTRL_ROWS: [&[SynthControlField]; 3] = [ // Row 0: OSC1 | OSC2 (side by side visually) &[ - SynthControlField::Osc1Waveform, SynthControlField::Osc1Tune, SynthControlField::Osc1Pwm, SynthControlField::Osc1Level, - SynthControlField::Osc2Waveform, SynthControlField::Osc2Tune, SynthControlField::Osc2Pwm, SynthControlField::Osc2Level, SynthControlField::Osc2Detune, SynthControlField::SubLevel, + SynthControlField::Osc1Waveform, SynthControlField::Osc1Tune, SynthControlField::Osc1Pwm, SynthControlField::Osc1Level, SynthControlField::Glide, + SynthControlField::Osc2Waveform, SynthControlField::Osc2Tune, SynthControlField::Osc2Pwm, SynthControlField::Osc2Level, SynthControlField::Osc2Detune, SynthControlField::SubLevel, SynthControlField::SubWaveform, SynthControlField::OscSync, ], // Row 1: ENV1 | ENV2 | FILT (side by side visually) &[ SynthControlField::Env1Attack, SynthControlField::Env1Decay, SynthControlField::Env1Sustain, SynthControlField::Env1Release, SynthControlField::Env2Attack, SynthControlField::Env2Decay, SynthControlField::Env2Sustain, SynthControlField::Env2Release, - SynthControlField::FilterType, SynthControlField::FilterCutoff, SynthControlField::FilterResonance, SynthControlField::FilterEnvAmount, SynthControlField::FilterEnvAttack, SynthControlField::FilterEnvDecay, SynthControlField::FilterEnvSustain, SynthControlField::FilterEnvRelease, + SynthControlField::FilterType, SynthControlField::FilterCutoff, SynthControlField::FilterResonance, SynthControlField::FilterEnvAmount, SynthControlField::FilterKeyFollow, SynthControlField::FilterEnvAttack, SynthControlField::FilterEnvDecay, SynthControlField::FilterEnvSustain, SynthControlField::FilterEnvRelease, ], // Row 2: AMP | LFO1 | LFO2 &[ @@ -1102,6 +1102,8 @@ fn adjust_synth_field(app: &mut App, synth_id: SynthId, delta: f32) { pattern.params.mute = !pattern.params.mute; } else if field.is_enum() { let max_val: u8 = match field { + SynthControlField::OscSync => 1, // toggle: 0 or 1 + SynthControlField::SubWaveform => 2, // Sqr/Sin/Saw SynthControlField::FilterType => 2, SynthControlField::LfoWaveform | SynthControlField::Lfo2Waveform => 2, SynthControlField::LfoDivision | SynthControlField::Lfo2Division => 9, diff --git a/src/mouse.rs b/src/mouse.rs index c3e7bf8..a2153c3 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -396,6 +396,7 @@ const OSC1_SLIDERS: &[SynthControlField] = &[ SynthControlField::Osc1Tune, SynthControlField::Osc1Pwm, SynthControlField::Osc1Level, + SynthControlField::Glide, ]; const OSC2_SLIDERS: &[SynthControlField] = &[ @@ -424,6 +425,7 @@ const FILT_SLIDERS: &[SynthControlField] = &[ SynthControlField::FilterCutoff, SynthControlField::FilterResonance, SynthControlField::FilterEnvAmount, + SynthControlField::FilterKeyFollow, ]; const FILT_ENV_ADSR: &[SynthControlField] = &[ @@ -477,7 +479,19 @@ fn hit_test_synth_knobs(col: u16, row: u16, knobs_area: Rect) -> Option Some(SynthControlField::Osc2Waveform), + 0 | 1 => { + // Three zones: Osc2Waveform | SubWaveform | OscSync + let third = cols[2].width / 3; + let zone1 = cols[2].x + third; + let zone2 = cols[2].x + third * 2; + if col < zone1 { + Some(SynthControlField::Osc2Waveform) + } else if col < zone2 { + Some(SynthControlField::SubWaveform) + } else { + Some(SynthControlField::OscSync) + } + } _ => hit_slider_field(col, cols[2].x, cols[2].width, OSC2_SLIDERS), }; } @@ -645,6 +659,8 @@ 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 = match field { + SynthControlField::OscSync => 1, // toggle: 0 or 1 + SynthControlField::SubWaveform => 2, // Sqr/Sin/Saw SynthControlField::FilterType => 2, SynthControlField::LfoWaveform | SynthControlField::Lfo2Waveform => crate::sequencer::synth_pattern::NUM_LFO_WAVEFORMS - 1, diff --git a/src/presets/drum_presets.rs b/src/presets/drum_presets.rs index 46fbb26..a03a792 100644 --- a/src/presets/drum_presets.rs +++ b/src/presets/drum_presets.rs @@ -10,29 +10,37 @@ const fn ds(tune: f32, sweep: f32, color: f32, snap: f32, filter: f32, drive: f3 } // ── Kick Presets ───────────────────────────────────────────────────────────── +// Tuned for: dual-stage pitch env (stage1=2.5ms fast, stage2=color-controlled), +// subharmonic at 0.5× (octave below), BP/LP click blend, +// drive-dependent 2nd harmonic (low drive = less harmonics). +// Key adjustments vs prior version: +// - Raise tune slightly on sub-heavy presets so subharmonic stays audible (>22Hz) +// - Bump drive on warm/full presets to compensate for reduced harmonics at low drive +// - Ease snap on presets that relied on softer LP click (BP blend is sharper) +// - color controls stage-2 only; low values still work but settle phase differs pub static KICK_PRESETS: &[DrumSoundPreset] = &[ - // 808 — sub-osc adds low-end, so reduce volume/decay vs original - DrumSoundPreset { name: "Deep 808", category: "808", voice: DrumTrackId::Kick, params: ds(0.20, 0.65, 0.15, 0.10, 0.55, 0.10, 0.70, 0.78) }, - DrumSoundPreset { name: "Punchy 808", category: "808", voice: DrumTrackId::Kick, params: ds(0.30, 0.55, 0.20, 0.50, 0.70, 0.15, 0.45, 0.75) }, - DrumSoundPreset { name: "Sub 808", category: "808", voice: DrumTrackId::Kick, params: ds(0.15, 0.70, 0.10, 0.05, 0.35, 0.05, 0.80, 0.80) }, - DrumSoundPreset { name: "Short 808", category: "808", voice: DrumTrackId::Kick, params: ds(0.25, 0.50, 0.20, 0.40, 0.60, 0.15, 0.28, 0.75) }, - // 909 — per-track saturation adds punch, ease drive - DrumSoundPreset { name: "Hard 909", category: "909", voice: DrumTrackId::Kick, params: ds(0.35, 0.45, 0.30, 0.60, 0.80, 0.25, 0.40, 0.80) }, - DrumSoundPreset { name: "Soft 909", category: "909", voice: DrumTrackId::Kick, params: ds(0.30, 0.40, 0.25, 0.30, 0.60, 0.08, 0.45, 0.72) }, - DrumSoundPreset { name: "Boom 909", category: "909", voice: DrumTrackId::Kick, params: ds(0.25, 0.60, 0.20, 0.45, 0.55, 0.15, 0.55, 0.75) }, - // Acoustic — tighter decay, sub adds natural weight - DrumSoundPreset { name: "Tight Acoustic", category: "Acoustic", voice: DrumTrackId::Kick, params: ds(0.40, 0.30, 0.35, 0.70, 0.90, 0.12, 0.30, 0.75) }, - DrumSoundPreset { name: "Jazz Kick", category: "Acoustic", voice: DrumTrackId::Kick, params: ds(0.45, 0.20, 0.40, 0.50, 0.70, 0.05, 0.35, 0.68) }, - // Lo-Fi - DrumSoundPreset { name: "Dusty Kick", category: "Lo-Fi", voice: DrumTrackId::Kick, params: ds(0.30, 0.50, 0.45, 0.35, 0.45, 0.35, 0.50, 0.70) }, - DrumSoundPreset { name: "Tape Kick", category: "Lo-Fi", voice: DrumTrackId::Kick, params: ds(0.25, 0.55, 0.50, 0.20, 0.40, 0.45, 0.50, 0.68) }, - // Industrial — saturation stacks with per-track, ease drive - DrumSoundPreset { name: "Distorted Kick", category: "Industrial", voice: DrumTrackId::Kick, params: ds(0.30, 0.65, 0.60, 0.80, 0.90, 0.70, 0.35, 0.80) }, - DrumSoundPreset { name: "Metal Kick", category: "Industrial", voice: DrumTrackId::Kick, params: ds(0.40, 0.75, 0.70, 0.90, 1.00, 0.60, 0.30, 0.75) }, - // Minimal — short decay tames sub tail - DrumSoundPreset { name: "Click Kick", category: "Minimal", voice: DrumTrackId::Kick, params: ds(0.35, 0.30, 0.10, 0.80, 0.70, 0.05, 0.18, 0.72) }, - DrumSoundPreset { name: "Micro Kick", category: "Minimal", voice: DrumTrackId::Kick, params: ds(0.40, 0.20, 0.15, 0.60, 0.50, 0.00, 0.12, 0.68) }, + // 808 — subharmonic at octave below; raise tune so sub stays audible + DrumSoundPreset { name: "Deep 808", category: "808", voice: DrumTrackId::Kick, params: ds(0.25, 0.65, 0.15, 0.10, 0.55, 0.18, 0.70, 0.78) }, + DrumSoundPreset { name: "Punchy 808", category: "808", voice: DrumTrackId::Kick, params: ds(0.30, 0.55, 0.20, 0.45, 0.70, 0.20, 0.45, 0.75) }, + DrumSoundPreset { name: "Sub 808", category: "808", voice: DrumTrackId::Kick, params: ds(0.22, 0.70, 0.10, 0.05, 0.35, 0.12, 0.80, 0.80) }, + DrumSoundPreset { name: "Short 808", category: "808", voice: DrumTrackId::Kick, params: ds(0.28, 0.50, 0.20, 0.35, 0.60, 0.20, 0.28, 0.75) }, + // 909 — BP click blend is sharper; ease snap slightly on softer presets + DrumSoundPreset { name: "Hard 909", category: "909", voice: DrumTrackId::Kick, params: ds(0.35, 0.45, 0.30, 0.55, 0.80, 0.28, 0.40, 0.80) }, + DrumSoundPreset { name: "Soft 909", category: "909", voice: DrumTrackId::Kick, params: ds(0.30, 0.40, 0.25, 0.25, 0.60, 0.15, 0.45, 0.72) }, + DrumSoundPreset { name: "Boom 909", category: "909", voice: DrumTrackId::Kick, params: ds(0.28, 0.60, 0.20, 0.40, 0.55, 0.20, 0.55, 0.75) }, + // Acoustic — bump drive for mid-range body (compensates lower harmonics at low drive) + DrumSoundPreset { name: "Tight Acoustic", category: "Acoustic", voice: DrumTrackId::Kick, params: ds(0.40, 0.30, 0.35, 0.65, 0.90, 0.18, 0.30, 0.75) }, + DrumSoundPreset { name: "Jazz Kick", category: "Acoustic", voice: DrumTrackId::Kick, params: ds(0.45, 0.20, 0.40, 0.45, 0.70, 0.12, 0.35, 0.68) }, + // Lo-Fi — drive already moderate-high, harmonics naturally present + DrumSoundPreset { name: "Dusty Kick", category: "Lo-Fi", voice: DrumTrackId::Kick, params: ds(0.30, 0.50, 0.45, 0.30, 0.45, 0.35, 0.50, 0.70) }, + DrumSoundPreset { name: "Tape Kick", category: "Lo-Fi", voice: DrumTrackId::Kick, params: ds(0.28, 0.55, 0.50, 0.18, 0.40, 0.45, 0.50, 0.68) }, + // Industrial — high drive benefits from asymmetric saturation; ease snap for BP blend + DrumSoundPreset { name: "Distorted Kick", category: "Industrial", voice: DrumTrackId::Kick, params: ds(0.30, 0.65, 0.60, 0.70, 0.90, 0.70, 0.35, 0.78) }, + DrumSoundPreset { name: "Metal Kick", category: "Industrial", voice: DrumTrackId::Kick, params: ds(0.40, 0.75, 0.70, 0.80, 1.00, 0.60, 0.30, 0.75) }, + // Minimal — short decay, BP click gives crisper transient; ease snap slightly + DrumSoundPreset { name: "Click Kick", category: "Minimal", voice: DrumTrackId::Kick, params: ds(0.35, 0.30, 0.10, 0.70, 0.70, 0.10, 0.18, 0.72) }, + DrumSoundPreset { name: "Micro Kick", category: "Minimal", voice: DrumTrackId::Kick, params: ds(0.40, 0.20, 0.15, 0.50, 0.50, 0.05, 0.12, 0.68) }, ]; // ── Snare Presets ──────────────────────────────────────────────────────────── diff --git a/src/presets/synth_presets.rs b/src/presets/synth_presets.rs index 8431269..c65e135 100644 --- a/src/presets/synth_presets.rs +++ b/src/presets/synth_presets.rs @@ -18,9 +18,13 @@ const fn sp( osc1_waveform, osc1_tune, osc1_pwm, osc1_level, osc2_waveform, osc2_tune, osc2_pwm, osc2_level, osc2_detune, sub_level, + sub_waveform: 0, // Square (default) env1_attack: env1_a, env1_decay: env1_d, env1_sustain: env1_s, env1_release: env1_r, env2_attack: env2_a, env2_decay: env2_d, env2_sustain: env2_s, env2_release: env2_r, + glide: 0.0, + osc_sync: 0, filter_type, filter_cutoff, filter_resonance, filter_env_amount, + filter_key_follow: 0.0, 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, @@ -30,6 +34,34 @@ const fn sp( } } +/// Override glide on a preset +#[allow(dead_code)] +const fn with_glide(mut p: SynthParams, glide: f32) -> SynthParams { + p.glide = glide; + p +} + +/// Override osc sync on a preset +#[allow(dead_code)] +const fn with_sync(mut p: SynthParams) -> SynthParams { + p.osc_sync = 1; + p +} + +/// Override sub waveform on a preset (0=Sqr, 1=Sin, 2=Saw) +#[allow(dead_code)] +const fn with_sub_waveform(mut p: SynthParams, wf: u8) -> SynthParams { + p.sub_waveform = wf; + p +} + +/// Override filter key follow on a preset +#[allow(dead_code)] +const fn with_key_follow(mut p: SynthParams, amount: f32) -> SynthParams { + p.filter_key_follow = amount; + p +} + /// Override LFO1 settings on a preset const fn with_lfo(mut p: SynthParams, wave: u8, div: f32, depth: f32, dest: u8) -> SynthParams { p.lfo_waveform = wave; diff --git a/src/sequencer/drum_pattern.rs b/src/sequencer/drum_pattern.rs index 33256b4..f385ceb 100644 --- a/src/sequencer/drum_pattern.rs +++ b/src/sequencer/drum_pattern.rs @@ -62,8 +62,8 @@ impl DrumTrackParams { pub fn defaults_for(track: DrumTrackId) -> Self { match track { DrumTrackId::Kick => Self { - tune: 0.3, sweep: 0.6, color: 0.2, snap: 0.5, - filter: 0.7, drive: 0.15, decay: 0.45, volume: 0.75, + tune: 0.3, sweep: 0.6, color: 0.2, snap: 0.45, + filter: 0.7, drive: 0.20, decay: 0.45, volume: 0.75, send_reverb: 0.05, send_delay: 0.0, pan: 0.5, mute: false, solo: false, }, DrumTrackId::Snare => Self { diff --git a/src/sequencer/synth_pattern.rs b/src/sequencer/synth_pattern.rs index d56bc2d..fe283ed 100644 --- a/src/sequencer/synth_pattern.rs +++ b/src/sequencer/synth_pattern.rs @@ -70,9 +70,11 @@ pub struct SynthParams { #[serde(default)] pub osc2_detune: f32, // 0.0-1.0, maps to -50..+50 cents (0.5 = no detune) - // Sub oscillator (square, 1 oct below osc2) + // Sub oscillator (1 oct below osc2) #[serde(default)] pub sub_level: f32, // 0.0-1.0 + #[serde(default)] + pub sub_waveform: u8, // 0=Square, 1=Sine, 2=Saw // Envelope 1 (osc1 amplitude) #[serde(default)] @@ -94,6 +96,14 @@ pub struct SynthParams { #[serde(default)] pub env2_release: f32, + // Portamento + #[serde(default)] + pub glide: f32, // 0.0-1.0, mapped to glide time (0=off, 1=slow) + + // Osc sync (hard-sync osc2 to osc1) + #[serde(default)] + pub osc_sync: u8, // 0=off, 1=on + // Filter (24dB multimode) #[serde(default)] pub filter_type: u8, // 0=LP, 1=HP, 2=BP @@ -103,6 +113,8 @@ pub struct SynthParams { pub filter_resonance: f32, #[serde(default)] pub filter_env_amount: f32, // 0.0-1.0 + #[serde(default)] + pub filter_key_follow: f32, // 0.0-1.0, how much note pitch affects cutoff // Filter envelope #[serde(default)] @@ -165,6 +177,7 @@ impl Default for SynthParams { // Sub: off sub_level: 0.0, + sub_waveform: 0, // Square // Env1: nice pluck/pad env1_attack: 0.01, @@ -178,11 +191,18 @@ impl Default for SynthParams { env2_sustain: 0.7, env2_release: 0.2, + // Portamento: off + glide: 0.0, + + // Osc sync: off + osc_sync: 0, + // Filter: mostly open, LP filter_type: 0, filter_cutoff: 0.7, filter_resonance: 0.0, filter_env_amount: 0.0, + filter_key_follow: 0.0, // Filter env filter_env_attack: 0.0, @@ -246,6 +266,7 @@ pub enum SynthControlField { Osc2Detune, // Sub SubLevel, + SubWaveform, // Env1 Env1Attack, Env1Decay, @@ -256,11 +277,16 @@ pub enum SynthControlField { Env2Decay, Env2Sustain, Env2Release, + // Portamento + Glide, + // Osc sync + OscSync, // Filter FilterType, FilterCutoff, FilterResonance, FilterEnvAmount, + FilterKeyFollow, // Filter Env FilterEnvAttack, FilterEnvDecay, @@ -297,6 +323,7 @@ impl SynthControlField { Self::Osc2Level => "Level", Self::Osc2Detune => "Detune", Self::SubLevel => "Sub", + Self::SubWaveform => "SubWv", Self::Env1Attack => "Atk", Self::Env1Decay => "Dec", Self::Env1Sustain => "Sus", @@ -305,10 +332,13 @@ impl SynthControlField { Self::Env2Decay => "Dec", Self::Env2Sustain => "Sus", Self::Env2Release => "Rel", + Self::Glide => "Glide", + Self::OscSync => "Sync", Self::FilterType => "Type", Self::FilterCutoff => "Freq", Self::FilterResonance => "Res", Self::FilterEnvAmount => "EnvAmt", + Self::FilterKeyFollow => "KeyFl", Self::FilterEnvAttack => "Atk", Self::FilterEnvDecay => "Dec", Self::FilterEnvSustain => "Sus", @@ -340,6 +370,7 @@ impl SynthControlField { Self::Osc2Level => p.osc2_level, Self::Osc2Detune => p.osc2_detune, Self::SubLevel => p.sub_level, + Self::SubWaveform => p.sub_waveform as f32 / 2.0, Self::Env1Attack => p.env1_attack, Self::Env1Decay => p.env1_decay, Self::Env1Sustain => p.env1_sustain, @@ -348,10 +379,13 @@ impl SynthControlField { Self::Env2Decay => p.env2_decay, Self::Env2Sustain => p.env2_sustain, Self::Env2Release => p.env2_release, + Self::Glide => p.glide, + Self::OscSync => p.osc_sync as f32, Self::FilterType => p.filter_type as f32 / 2.0, Self::FilterCutoff => p.filter_cutoff, Self::FilterResonance => p.filter_resonance, Self::FilterEnvAmount => p.filter_env_amount, + Self::FilterKeyFollow => p.filter_key_follow, Self::FilterEnvAttack => p.filter_env_attack, Self::FilterEnvDecay => p.filter_env_decay, Self::FilterEnvSustain => p.filter_env_sustain, @@ -384,6 +418,7 @@ impl SynthControlField { Self::Osc2Level => p.osc2_level = v, Self::Osc2Detune => p.osc2_detune = v, Self::SubLevel => p.sub_level = v, + Self::SubWaveform => p.sub_waveform = (v * 2.0).round() as u8, Self::Env1Attack => p.env1_attack = v, Self::Env1Decay => p.env1_decay = v, Self::Env1Sustain => p.env1_sustain = v, @@ -392,10 +427,13 @@ impl SynthControlField { Self::Env2Decay => p.env2_decay = v, Self::Env2Sustain => p.env2_sustain = v, Self::Env2Release => p.env2_release = v, + Self::Glide => p.glide = v, + Self::OscSync => p.osc_sync = if v > 0.5 { 1 } else { 0 }, Self::FilterType => p.filter_type = (v * 2.0).round() as u8, Self::FilterCutoff => p.filter_cutoff = v, Self::FilterResonance => p.filter_resonance = v, Self::FilterEnvAmount => p.filter_env_amount = v, + Self::FilterKeyFollow => p.filter_key_follow = v, Self::FilterEnvAttack => p.filter_env_attack = v, Self::FilterEnvDecay => p.filter_env_decay = v, Self::FilterEnvSustain => p.filter_env_sustain = v, @@ -416,7 +454,8 @@ impl SynthControlField { } pub fn is_enum(&self) -> bool { - matches!(self, Self::Osc1Waveform | Self::Osc2Waveform | Self::FilterType + matches!(self, Self::Osc1Waveform | Self::Osc2Waveform | Self::SubWaveform + | Self::OscSync | Self::FilterType | 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 04094c2..a33efef 100644 --- a/src/ui/synth_knobs.rs +++ b/src/ui/synth_knobs.rs @@ -23,6 +23,7 @@ const OSC1_SLIDERS: &[SynthControlField] = &[ SynthControlField::Osc1Tune, SynthControlField::Osc1Pwm, SynthControlField::Osc1Level, + SynthControlField::Glide, ]; const OSC2_SLIDERS: &[SynthControlField] = &[ @@ -37,6 +38,7 @@ const FILT_SLIDERS: &[SynthControlField] = &[ SynthControlField::FilterCutoff, SynthControlField::FilterResonance, SynthControlField::FilterEnvAmount, + SynthControlField::FilterKeyFollow, ]; const ENV1_ADSR: &[SynthControlField] = &[ @@ -136,12 +138,28 @@ pub fn render_synth_knobs(f: &mut Frame, area: Rect, app: &App, synth_id: SynthI ); render_section_header(f, cols[2], "OSC2"); - render_waveform_selector( - f, - Rect::new(cols[2].x, cols[2].y + 1, cols[2].width, 1), - params.osc2_waveform, - focused && sel == SynthControlField::Osc2Waveform, - ); + // Row 1: Osc2 waveform (left) | Sub waveform (center) | Sync toggle (right) + { + let third = cols[2].width / 3; + render_waveform_selector( + f, + Rect::new(cols[2].x, cols[2].y + 1, third, 1), + params.osc2_waveform, + focused && sel == SynthControlField::Osc2Waveform, + ); + render_sub_waveform_selector( + f, + Rect::new(cols[2].x + third, cols[2].y + 1, third, 1), + params.sub_waveform, + focused && sel == SynthControlField::SubWaveform, + ); + render_sync_toggle( + f, + Rect::new(cols[2].x + third * 2, cols[2].y + 1, cols[2].width - third * 2, 1), + params.osc_sync > 0, + focused && sel == SynthControlField::OscSync, + ); + } render_slider_group( f, Rect::new(cols[2].x, cols[2].y + 2, cols[2].width, 6), @@ -307,6 +325,49 @@ fn render_waveform_selector(f: &mut Frame, area: Rect, current: u8, is_selected: f.render_widget(Paragraph::new(Line::from(spans)), area); } +// ── Sync toggle: [X]Sync or [ ]Sync ───────────────────────────────────────── + +fn render_sync_toggle(f: &mut Frame, area: Rect, enabled: bool, is_selected: bool) { + let color = if is_selected { theme::PINK } else if enabled { theme::AMBER } else { theme::DIM_TEXT }; + let check = if enabled { "X" } else { " " }; + let spans = vec![ + Span::styled("[", Style::default().fg(color)), + Span::styled(check, Style::default().fg(color).add_modifier(Modifier::BOLD)), + Span::styled("]", Style::default().fg(color)), + Span::styled("Sync", Style::default().fg(color)), + ]; + f.render_widget(Paragraph::new(Line::from(spans)), area); +} + +// ── Sub waveform selector: [Sqr] Sin Saw ──────────────────────────────────── + +fn render_sub_waveform_selector(f: &mut Frame, area: Rect, current: u8, is_selected: bool) { + let names = ["Sqr", "Sin", "Saw"]; + let mut spans: Vec = Vec::new(); + spans.push(Span::styled("Sub:", Style::default().fg(theme::DIM_TEXT))); + + for (i, name) in names.iter().enumerate() { + let is_active = i as u8 == current; + if is_active { + let color = if is_selected { theme::PINK } else { theme::AMBER }; + spans.push(Span::styled("[", Style::default().fg(color))); + spans.push(Span::styled( + *name, + Style::default().fg(color).add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled("]", Style::default().fg(color))); + } else { + spans.push(Span::styled( + *name, + Style::default().fg(theme::DIM_TEXT), + )); + } + spans.push(Span::raw(" ")); + } + + f.render_widget(Paragraph::new(Line::from(spans)), area); +} + // ── Filter type selector: [LP] HP BP ───────────────────────────────────────── fn render_filter_type_selector(f: &mut Frame, area: Rect, current: u8, is_selected: bool) {