diff --git a/src/app.rs b/src/app.rs index c03755f..05b01da 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,7 +8,7 @@ use crossbeam_channel::{Receiver, Sender}; use crate::audio::display_buffer::AudioDisplayBuffer; -use crate::messages::{AudioToUi, UiToAudio}; +use crate::messages::{AudioToUi, SynthId, UiToAudio}; use crate::params::EffectParams; use crate::sequencer::drum_pattern::{DrumPattern, NUM_DRUM_TRACKS}; use crate::sequencer::project::{self, ProjectFile, NUM_KITS, NUM_PATTERNS}; @@ -17,33 +17,60 @@ use crate::sequencer::transport::Transport; // ── Focus & field enums ───────────────────────────────────────────────────── -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FocusSection { DrumGrid, Knobs, - SynthGrid, - SynthControls, + SynthAGrid, // was: SynthGrid + SynthAControls, // was: SynthControls + SynthBGrid, // new + SynthBControls, // new Transport, } impl FocusSection { - pub fn next(self) -> Self { - match self { - FocusSection::DrumGrid => FocusSection::Knobs, - FocusSection::Knobs => FocusSection::SynthGrid, - FocusSection::SynthGrid => FocusSection::SynthControls, - FocusSection::SynthControls => FocusSection::Transport, - FocusSection::Transport => FocusSection::DrumGrid, + pub fn next(&self, vis: &PanelVisibility) -> Self { + use FocusSection::*; + let order = [ + Transport, SynthAControls, SynthAGrid, + SynthBControls, SynthBGrid, DrumGrid, Knobs, + ]; + let cur = order.iter().position(|s| s == self).unwrap_or(0); + for i in 1..=order.len() { + let candidate = order[(cur + i) % order.len()]; + if candidate.is_visible(vis) { + return candidate; + } + } + Transport + } + + pub fn prev(&self, vis: &PanelVisibility) -> Self { + use FocusSection::*; + let order = [ + Transport, SynthAControls, SynthAGrid, + SynthBControls, SynthBGrid, DrumGrid, Knobs, + ]; + let cur = order.iter().position(|s| s == self).unwrap_or(0); + for i in 1..=order.len() { + let candidate = order[(cur + order.len() - i) % order.len()]; + if candidate.is_visible(vis) { + return candidate; + } } + Transport } - pub fn prev(self) -> Self { + pub fn is_visible(&self, vis: &PanelVisibility) -> bool { + use FocusSection::*; match self { - FocusSection::DrumGrid => FocusSection::Transport, - FocusSection::Knobs => FocusSection::DrumGrid, - FocusSection::SynthGrid => FocusSection::Knobs, - FocusSection::SynthControls => FocusSection::SynthGrid, - FocusSection::Transport => FocusSection::SynthControls, + Transport => true, + SynthAControls => vis.synth_a_knobs, + SynthAGrid => vis.synth_a_grid, + SynthBControls => vis.synth_b_knobs, + SynthBGrid => vis.synth_b_grid, + DrumGrid => vis.drum_grid, + Knobs => vis.drum_knobs, } } } @@ -371,6 +398,7 @@ pub enum FaderKind { /// State for synth knob drag. #[derive(Clone, Debug)] pub struct SynthDrag { + pub synth_id: crate::messages::SynthId, pub field: crate::sequencer::synth_pattern::SynthControlField, pub start_y: u16, pub start_value: f32, @@ -379,6 +407,7 @@ pub struct SynthDrag { /// State for synth note length drag (horizontal resize). #[derive(Clone, Debug)] pub struct SynthNoteDrag { + pub synth_id: crate::messages::SynthId, pub step: usize, pub original_length: u8, pub start_col: u16, @@ -419,6 +448,58 @@ pub struct StatusMessage { pub frames_remaining: u16, } +#[derive(Clone, Debug)] +pub struct PanelVisibility { + pub synth_a_knobs: bool, + pub synth_a_grid: bool, + pub synth_b_knobs: bool, + pub synth_b_grid: bool, + pub drum_grid: bool, + pub drum_knobs: bool, + pub waveform: bool, +} + +impl Default for PanelVisibility { + fn default() -> Self { + Self { + synth_a_knobs: true, + synth_a_grid: true, + synth_b_knobs: false, // Synth B collapsed by default + synth_b_grid: false, + drum_grid: true, + drum_knobs: true, + waveform: true, + } + } +} + +#[derive(Clone, Debug)] +pub struct SynthUiState { + pub playback_step: usize, + pub cursor_step: usize, + pub ctrl_field: SynthControlField, + pub flash: u8, + pub octave: u8, + pub active_pattern: usize, + pub queued_pattern: Option, + pub active_kit: usize, +} + +impl Default for SynthUiState { + fn default() -> Self { + Self { + playback_step: 0, + cursor_step: 0, + ctrl_field: SynthControlField::Osc1Waveform, + flash: 0, + octave: 4, + active_pattern: 0, + queued_pattern: None, + active_kit: 0, + } + } +} + pub struct UiState { pub splash: SplashState, pub focus: FocusSection, @@ -432,7 +513,7 @@ pub struct UiState { pub is_bar_start: bool, pub show_help: bool, pub show_waveform: bool, - pub synth_collapsed: bool, + pub panel_vis: PanelVisibility, /// Per-track trigger flash countdown (> 0 means flashing) pub trigger_flash: [u8; NUM_DRUM_TRACKS], /// Current active pattern index (0-9) @@ -452,19 +533,8 @@ pub struct UiState { /// Scope bar intensity/brightness (0.0-1.0), decays faster than bars for glow effect pub scope_intensity: Vec, // ── Synth state ── - pub synth_playback_step: usize, - pub synth_cursor_step: usize, - pub synth_ctrl_field: SynthControlField, - /// Synth trigger flash countdown - pub synth_flash: u8, - /// Current octave for synth note entry - pub synth_octave: u8, - /// Current active synth pattern index (0-9) - pub synth_active_pattern: usize, - /// Queued synth pattern to switch to at end of loop (None = no change pending) - pub synth_queued_pattern: Option, - /// Current active synth kit index (0-7) - pub synth_active_kit: usize, + pub synth_a: SynthUiState, + pub synth_b: SynthUiState, } impl UiState { @@ -498,7 +568,7 @@ impl Default for UiState { is_bar_start: false, show_help: false, show_waveform: true, - synth_collapsed: false, + panel_vis: PanelVisibility::default(), trigger_flash: [0; NUM_DRUM_TRACKS], active_pattern: 0, queued_pattern: None, @@ -508,14 +578,8 @@ impl Default for UiState { mouse: MouseState::default(), scope_bars: Vec::new(), scope_intensity: Vec::new(), - synth_playback_step: 0, - synth_cursor_step: 0, - synth_ctrl_field: SynthControlField::Osc1Waveform, - synth_flash: 0, - synth_octave: 4, - synth_active_pattern: 0, - synth_queued_pattern: None, - synth_active_kit: 0, + synth_a: SynthUiState::default(), + synth_b: SynthUiState::default(), } } } @@ -526,7 +590,8 @@ pub struct App { pub ui: UiState, pub transport: Transport, pub drum_pattern: DrumPattern, - pub synth_pattern: SynthPattern, + pub synth_a_pattern: SynthPattern, + pub synth_b_pattern: SynthPattern, pub effect_params: EffectParams, pub project: ProjectFile, pub project_path: Option, @@ -555,7 +620,7 @@ impl App { } } - let mut synth_pattern = SynthPattern::default(); + let mut synth_a_pattern = SynthPattern::default(); // Load startup presets: "Four on the Floor" drum + "Techno 2" synth { @@ -579,9 +644,9 @@ impl App { if let Some(preset) = SYNTH_PATTERN_PRESETS.iter().find(|p| p.name == "Techno 2") { for (i, &(note, vel, len)) in preset.steps.iter().enumerate() { - synth_pattern.steps[i].note = note; - synth_pattern.steps[i].velocity = vel; - synth_pattern.steps[i].length = len; + synth_a_pattern.steps[i].note = note; + synth_a_pattern.steps[i].velocity = vel; + synth_a_pattern.steps[i].length = len; } } } @@ -589,13 +654,14 @@ impl App { // Send initial state to audio thread so it has the pattern from the start let _ = tx.send(UiToAudio::SetTransport(transport)); let _ = tx.send(UiToAudio::SetDrumPattern(drum_pattern.clone())); - let _ = tx.send(UiToAudio::SetSynthPattern(synth_pattern.clone())); + let _ = tx.send(UiToAudio::SetSynthPattern(SynthId::A, synth_a_pattern.clone())); Self { ui: UiState::default(), transport, drum_pattern, - synth_pattern, + synth_a_pattern, + synth_b_pattern: SynthPattern::default(), effect_params: EffectParams::default(), project, project_path: None, @@ -625,7 +691,7 @@ impl App { pub fn tick(&mut self) { // Decay trigger flashes each frame self.ui.decay_flashes(); - self.ui.synth_flash = self.ui.synth_flash.saturating_sub(1); + self.ui.synth_a.flash = self.ui.synth_a.flash.saturating_sub(1); // Update scope bars (only when visible to avoid unnecessary work) if self.ui.show_waveform { @@ -647,12 +713,15 @@ impl App { beat, is_bar_start, triggered, - synth_triggered, + synth_a_triggered, drum_step, - synth_step, + synth_a_step, + synth_b_step, + synth_b_triggered, } => { self.ui.playback_step = drum_step; - self.ui.synth_playback_step = synth_step; + self.ui.synth_a.playback_step = synth_a_step; + self.ui.synth_b.playback_step = synth_b_step; self.ui.current_beat = beat; self.ui.is_bar_start = is_bar_start; @@ -663,9 +732,9 @@ impl App { } } - // Check for queued synth pattern switch at loop wrap (step 0) - if synth_step == 0 && global_step > 0 { - if let Some(next) = self.ui.synth_queued_pattern.take() { + // Check for queued synth A pattern switch at loop wrap (step 0) + if synth_a_step == 0 && global_step > 0 { + if let Some(next) = self.ui.synth_a.queued_pattern.take() { self.switch_synth_pattern(next); } } @@ -677,9 +746,12 @@ impl App { } } - // Flash synth - if synth_triggered { - self.ui.synth_flash = FLASH_FRAMES; + // Flash synths + if synth_a_triggered { + self.ui.synth_a.flash = FLASH_FRAMES; + } + if synth_b_triggered { + self.ui.synth_b.flash = FLASH_FRAMES; } } } @@ -752,11 +824,15 @@ impl App { .send(UiToAudio::SetDrumPattern(self.drum_pattern.clone())); } - /// Send the synth pattern to the audio thread. - pub fn send_synth_pattern(&self) { + /// Send the synth pattern to the audio thread for the specified synth. + pub fn send_synth_pattern(&self, synth_id: SynthId) { + let pattern = match synth_id { + SynthId::A => &self.synth_a_pattern, + SynthId::B => &self.synth_b_pattern, + }; let _ = self .tx_to_audio - .send(UiToAudio::SetSynthPattern(self.synth_pattern.clone())); + .send(UiToAudio::SetSynthPattern(synth_id, pattern.clone())); } /// Send effect params to the audio thread. @@ -809,10 +885,10 @@ impl App { pat.bpm = self.transport.bpm; } // Save synth pattern and kit - self.project.save_synth_pattern(self.ui.synth_active_pattern, &self.synth_pattern); - self.project.save_synth_kit(self.ui.synth_active_kit, &self.synth_pattern.params); - self.project.active_synth_pattern = self.ui.synth_active_pattern; - self.project.active_synth_kit = self.ui.synth_active_kit; + self.project.save_synth_pattern(self.ui.synth_a.active_pattern, &self.synth_a_pattern); + self.project.save_synth_kit(self.ui.synth_a.active_kit, &self.synth_a_pattern.params); + self.project.active_synth_pattern = self.ui.synth_a.active_pattern; + self.project.active_synth_kit = self.ui.synth_a.active_kit; } /// Switch to a different pattern immediately. @@ -853,23 +929,23 @@ impl App { pub fn switch_synth_pattern(&mut self, index: usize) { if index >= NUM_PATTERNS { return; } // Save current synth pattern first - self.project.save_synth_pattern(self.ui.synth_active_pattern, &self.synth_pattern); + self.project.save_synth_pattern(self.ui.synth_a.active_pattern, &self.synth_a_pattern); // Load new synth pattern - self.ui.synth_active_pattern = index; + self.ui.synth_a.active_pattern = index; self.project.active_synth_pattern = index; - self.synth_pattern = SynthPattern::default(); - self.project.load_synth_pattern(index, &mut self.synth_pattern); - self.send_synth_pattern(); + self.synth_a_pattern = SynthPattern::default(); + self.project.load_synth_pattern(index, &mut self.synth_a_pattern); + self.send_synth_pattern(SynthId::A); } /// Queue a synth pattern to switch at end of current loop. pub fn queue_synth_pattern(&mut self, index: usize) { if index >= NUM_PATTERNS { return; } - if index == self.ui.synth_active_pattern { + if index == self.ui.synth_a.active_pattern { // Pressing the same pattern cancels the queue - self.ui.synth_queued_pattern = None; + self.ui.synth_a.queued_pattern = None; } else { - self.ui.synth_queued_pattern = Some(index); + self.ui.synth_a.queued_pattern = Some(index); } } @@ -877,12 +953,68 @@ impl App { pub fn switch_synth_kit(&mut self, index: usize) { if index >= NUM_KITS { return; } // Save current synth kit params back - self.project.save_synth_kit(self.ui.synth_active_kit, &self.synth_pattern.params); + self.project.save_synth_kit(self.ui.synth_a.active_kit, &self.synth_a_pattern.params); // Load new synth kit - self.ui.synth_active_kit = index; + self.ui.synth_a.active_kit = index; self.project.active_synth_kit = index; - self.project.load_synth_kit(index, &mut self.synth_pattern); - self.send_synth_pattern(); + self.project.load_synth_kit(index, &mut self.synth_a_pattern); + self.send_synth_pattern(SynthId::A); + } + + // ── Focus-aware synth helpers (dual synth) ───────────────────────── + + /// Switch to a different synth pattern for the specified synth. + pub fn switch_synth_pattern_for(&mut self, synth_id: SynthId, index: usize) { + if index >= NUM_PATTERNS { return; } + match synth_id { + SynthId::A => { + self.project.save_synth_pattern(self.ui.synth_a.active_pattern, &self.synth_a_pattern); + self.ui.synth_a.active_pattern = index; + self.project.active_synth_pattern = index; + self.synth_a_pattern = SynthPattern::default(); + self.project.load_synth_pattern(index, &mut self.synth_a_pattern); + self.send_synth_pattern(SynthId::A); + } + SynthId::B => { + // For synth B, use synth_b_pattern (project B storage is a future task) + self.ui.synth_b.active_pattern = index; + self.synth_b_pattern = SynthPattern::default(); + self.send_synth_pattern(SynthId::B); + } + } + } + + /// Queue a synth pattern for the specified synth. + pub fn queue_synth_pattern_for(&mut self, synth_id: SynthId, index: usize) { + if index >= NUM_PATTERNS { return; } + let ui = match synth_id { + SynthId::A => &mut self.ui.synth_a, + SynthId::B => &mut self.ui.synth_b, + }; + if index == ui.active_pattern { + ui.queued_pattern = None; + } else { + ui.queued_pattern = Some(index); + } + } + + /// Switch to a different synth kit for the specified synth. + pub fn switch_synth_kit_for(&mut self, synth_id: SynthId, index: usize) { + if index >= NUM_KITS { return; } + match synth_id { + SynthId::A => { + self.project.save_synth_kit(self.ui.synth_a.active_kit, &self.synth_a_pattern.params); + self.ui.synth_a.active_kit = index; + self.project.active_synth_kit = index; + self.project.load_synth_kit(index, &mut self.synth_a_pattern); + self.send_synth_pattern(SynthId::A); + } + SynthId::B => { + // For synth B, just update UI state (project B storage is a future task) + self.ui.synth_b.active_kit = index; + self.send_synth_pattern(SynthId::B); + } + } } /// Show a brief status message. @@ -1034,12 +1166,19 @@ impl App { // ── Preset Browser ───────────────────────────────────────────────── pub fn open_preset_browser(&mut self) { - let is_synth = matches!(self.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); - let browser = if is_synth { + let is_synth = matches!(self.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls | FocusSection::SynthBGrid | FocusSection::SynthBControls); + let mut browser = if is_synth { crate::presets::PresetBrowserState::for_synth() } else { crate::presets::PresetBrowserState::for_drum_track(self.ui.drum_cursor_track) }; + // Set target synth based on current focus + if is_synth { + browser.target_synth = match self.ui.focus { + FocusSection::SynthBGrid | FocusSection::SynthBControls => SynthId::B, + _ => SynthId::A, + }; + } self.ui.modal = ModalState::PresetBrowser(browser); } @@ -1055,12 +1194,19 @@ impl App { } pub fn open_pattern_browser(&mut self) { - let is_synth = matches!(self.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); - let pb = if is_synth { + let is_synth = matches!(self.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls | FocusSection::SynthBGrid | FocusSection::SynthBControls); + let mut pb = if is_synth { crate::presets::PatternBrowserState::new_synth() } else { crate::presets::PatternBrowserState::new() }; + // Set target synth based on current focus + if is_synth { + pb.browser.target_synth = match self.ui.focus { + FocusSection::SynthBGrid | FocusSection::SynthBControls => SynthId::B, + _ => SynthId::A, + }; + } self.ui.modal = ModalState::PatternBrowser(pb); } @@ -1115,13 +1261,19 @@ impl App { pub fn apply_synth_pattern_preset( &mut self, + synth_id: SynthId, preset_steps: &[(u8, u8, u8); crate::sequencer::synth_pattern::MAX_STEPS], merge: crate::presets::PatternMergeMode, ) { use crate::sequencer::synth_pattern::{MAX_STEPS, SynthStep}; + let pattern = match synth_id { + SynthId::A => &mut self.synth_a_pattern, + SynthId::B => &mut self.synth_b_pattern, + }; + let fill_len = if self.transport.loop_config.enabled { - self.transport.loop_config.synth_length as usize + self.transport.loop_config.synth_a_length as usize } else { MAX_STEPS }; @@ -1136,27 +1288,31 @@ impl App { if vel > 0 { match merge { crate::presets::PatternMergeMode::Replace => { - self.synth_pattern.steps[s] = SynthStep { note, velocity: vel, length: len }; + pattern.steps[s] = SynthStep { note, velocity: vel, length: len }; } crate::presets::PatternMergeMode::Layer => { - if !self.synth_pattern.steps[s].is_active() { - self.synth_pattern.steps[s] = SynthStep { note, velocity: vel, length: len }; + if !pattern.steps[s].is_active() { + pattern.steps[s] = SynthStep { note, velocity: vel, length: len }; } } } } else if matches!(merge, crate::presets::PatternMergeMode::Replace) { - self.synth_pattern.steps[s] = SynthStep::default(); + pattern.steps[s] = SynthStep::default(); } } - self.send_synth_pattern(); + self.send_synth_pattern(synth_id); self.dirty = true; } - pub fn apply_synth_preset(&mut self, params: crate::sequencer::synth_pattern::SynthParams) { - let mute = self.synth_pattern.params.mute; - self.synth_pattern.params = params; - self.synth_pattern.params.mute = mute; - self.send_synth_pattern(); + pub fn apply_synth_preset(&mut self, synth_id: SynthId, params: crate::sequencer::synth_pattern::SynthParams) { + let pattern = match synth_id { + SynthId::A => &mut self.synth_a_pattern, + SynthId::B => &mut self.synth_b_pattern, + }; + let mute = pattern.params.mute; + pattern.params = params; + pattern.params.mute = mute; + self.send_synth_pattern(synth_id); self.dirty = true; } @@ -1188,12 +1344,12 @@ impl App { self.send_effect_params(); // Load synth state from project - self.ui.synth_active_pattern = self.project.active_synth_pattern; - self.ui.synth_active_kit = self.project.active_synth_kit; - self.synth_pattern = SynthPattern::default(); - self.project.load_synth_pattern(self.ui.synth_active_pattern, &mut self.synth_pattern); - self.project.load_synth_kit(self.ui.synth_active_kit, &mut self.synth_pattern); - self.send_synth_pattern(); + self.ui.synth_a.active_pattern = self.project.active_synth_pattern; + self.ui.synth_a.active_kit = self.project.active_synth_kit; + self.synth_a_pattern = SynthPattern::default(); + self.project.load_synth_pattern(self.ui.synth_a.active_pattern, &mut self.synth_a_pattern); + self.project.load_synth_kit(self.ui.synth_a.active_kit, &mut self.synth_a_pattern); + self.send_synth_pattern(SynthId::A); self.dirty = false; self.show_status(format!("Loaded: {}", name)); diff --git a/src/audio/engine.rs b/src/audio/engine.rs index daadd02..ace9ca6 100644 --- a/src/audio/engine.rs +++ b/src/audio/engine.rs @@ -10,7 +10,7 @@ use crate::audio::drum_voice::{create_drum_voices, DrumVoiceDsp}; use crate::audio::effects::{DelayEffect, GlueCompressor, ReverbEffect, TubeSaturator}; use crate::audio::mixer::{effective_mute, soft_clip}; use crate::audio::synth_voice::SynthVoice; -use crate::messages::{AudioToUi, UiToAudio}; +use crate::messages::{AudioToUi, SynthId, UiToAudio}; use crate::params::EffectParams; use crate::sequencer::drum_pattern::{DrumPattern, DrumTrackId, NUM_DRUM_TRACKS}; use crate::sequencer::synth_pattern::{SynthPattern, LFO_DEST_FIELDS, lfo_division_multiplier}; @@ -18,7 +18,7 @@ use crate::sequencer::transport::{PlayState, Transport}; /// Tempo-synced global LFO shared across synth voices. /// Supports sine, triangle, saw (up/down), square, and exponential decay waveforms. -struct Lfo { +pub(crate) struct Lfo { phase: f64, } @@ -75,6 +75,43 @@ impl Lfo { } } +/// Bundles all per-synth state (voice, pattern, effects, LFO). +pub struct SynthInstance { + pub pattern: SynthPattern, + pub voice: SynthVoice, + pub gate_samples: u32, + pub note_end_step: Option, + pub lfo: Lfo, + pub saturator: TubeSaturator, + pub reverb: ReverbEffect, + pub delay: DelayEffect, + pub reverb_amount: f32, + pub reverb_damping: f32, + pub delay_time: f32, + pub delay_feedback: f32, + pub delay_tone: f32, +} + +impl SynthInstance { + pub fn new(sample_rate: f64) -> Self { + Self { + pattern: SynthPattern::default(), + voice: SynthVoice::new(sample_rate as f32), + gate_samples: 0, + note_end_step: None, + lfo: Lfo::new(), + saturator: TubeSaturator::new(sample_rate as f32), + reverb: ReverbEffect::new(sample_rate), + delay: DelayEffect::new(), + reverb_amount: 0.3, + reverb_damping: 0.5, + delay_time: 0.0, + delay_feedback: 0.4, + delay_tone: 0.5, + } + } +} + /// Core audio engine running on the audio thread. /// Owns all voices, effects, the sequencer clock, and handles messages from the UI thread. pub struct AudioEngine { @@ -84,24 +121,18 @@ pub struct AudioEngine { // Local copies updated from UI messages transport: Transport, drum_pattern: DrumPattern, - synth_pattern: SynthPattern, master_volume: f32, // DSP drum_voices: [Box; 8], - synth_voice: SynthVoice, - lfo: Lfo, - /// Samples remaining for current synth note gate (0 = released/idle) - synth_gate_samples: u32, - /// Step index where the current long note should end (for multi-step notes) - synth_note_end_step: Option, - - // Send effects - reverb: ReverbEffect, - delay: DelayEffect, + synth_a: SynthInstance, + synth_b: SynthInstance, + + // Send effects (drum bus) + drum_reverb: ReverbEffect, + drum_delay: DelayEffect, compressor: GlueCompressor, drum_saturator: TubeSaturator, - synth_saturator: TubeSaturator, effect_params: EffectParams, // Display buffer (shared with UI) @@ -116,10 +147,10 @@ pub struct AudioEngine { impl AudioEngine { pub fn new(sample_rate: f64, rx: Receiver, tx: Sender, display_buf: Arc) -> Self { let effect_params = EffectParams::default(); - let mut reverb = ReverbEffect::new(sample_rate); - let mut delay = DelayEffect::new(); - reverb.set_params(effect_params.reverb_amount, effect_params.reverb_damping); - delay.set_params( + let mut drum_reverb = ReverbEffect::new(sample_rate); + let mut drum_delay = DelayEffect::new(); + drum_reverb.set_params(effect_params.reverb_amount, effect_params.reverb_damping); + drum_delay.set_params( effect_params.delay_time, effect_params.delay_feedback, effect_params.delay_tone, @@ -129,25 +160,20 @@ impl AudioEngine { let compressor = GlueCompressor::new(sample_rate); let drum_saturator = TubeSaturator::new(sample_rate as f32); - let synth_saturator = TubeSaturator::new(sample_rate as f32); Self { sample_rate, clock: SequencerClock::new(), transport: Transport::default(), drum_pattern: DrumPattern::default(), - synth_pattern: SynthPattern::default(), master_volume: 0.8, drum_voices: create_drum_voices(sample_rate), - synth_voice: SynthVoice::new(sample_rate as f32), - lfo: Lfo::new(), - synth_gate_samples: 0, - synth_note_end_step: None, - reverb, - delay, + synth_a: SynthInstance::new(sample_rate), + synth_b: SynthInstance::new(sample_rate), + drum_reverb, + drum_delay, compressor, drum_saturator, - synth_saturator, effect_params, display_buf, peak_tracker: 0.0, @@ -169,13 +195,15 @@ impl AudioEngine { && prev_state != PlayState::Stopped { self.clock.reset(); - self.lfo.reset(); - self.synth_note_end_step = None; + self.synth_a.lfo.reset(); + self.synth_b.lfo.reset(); + self.synth_a.note_end_step = None; + self.synth_b.note_end_step = None; } // Update delay time when BPM changes if bpm_changed { let ep = &self.effect_params; - self.delay.set_params( + self.drum_delay.set_params( ep.delay_time, ep.delay_feedback, ep.delay_tone, @@ -187,15 +215,23 @@ impl AudioEngine { UiToAudio::SetDrumPattern(p) => { self.drum_pattern = p; } - UiToAudio::SetSynthPattern(p) => { - self.synth_pattern = p; - self.synth_note_end_step = None; + UiToAudio::SetSynthPattern(synth_id, p) => { + match synth_id { + SynthId::A => { + self.synth_a.pattern = p; + self.synth_a.note_end_step = None; + } + SynthId::B => { + self.synth_b.pattern = p; + self.synth_b.note_end_step = None; + } + } } UiToAudio::SetEffectParams(ep) => { self.effect_params = ep; - self.reverb + self.drum_reverb .set_params(ep.reverb_amount, ep.reverb_damping); - self.delay.set_params( + self.drum_delay.set_params( ep.delay_time, ep.delay_feedback, ep.delay_tone, @@ -206,7 +242,8 @@ impl AudioEngine { .set_amount(ep.compressor_amount, self.sample_rate); self.master_volume = ep.master_volume; self.drum_saturator.set_drive(ep.drum_saturator_drive); - self.synth_saturator.set_drive(ep.synth_saturator_drive); + self.synth_a.saturator.set_drive(ep.synth_saturator_drive); + self.synth_b.saturator.set_drive(ep.synth_saturator_drive); } UiToAudio::TriggerDrum(track_id) => { let track = track_id as usize; @@ -216,15 +253,23 @@ impl AudioEngine { self.drum_voices[DrumTrackId::OpenHiHat as usize].choke(); } } - UiToAudio::TriggerSynth(note) => { - self.synth_voice.trigger(&self.synth_pattern.params, note); + UiToAudio::TriggerSynth(synth_id, note) => { + let inst = match synth_id { + SynthId::A => &mut self.synth_a, + SynthId::B => &mut self.synth_b, + }; + inst.voice.trigger(&inst.pattern.params, note); // Gate for ~half a step (will be released when gate runs out) let samples_per_step = (self.sample_rate * 60.0 / self.transport.bpm / 4.0) as u32; - self.synth_gate_samples = samples_per_step * 3 / 4; + inst.gate_samples = samples_per_step * 3 / 4; } - UiToAudio::ReleaseSynth => { - self.synth_voice.release(); - self.synth_gate_samples = 0; + UiToAudio::ReleaseSynth(synth_id) => { + let inst = match synth_id { + SynthId::A => &mut self.synth_a, + SynthId::B => &mut self.synth_b, + }; + inst.voice.release(); + inst.gate_samples = 0; } } } @@ -242,8 +287,13 @@ impl AudioEngine { } else { crate::sequencer::drum_pattern::MAX_STEPS }; - let synth_loop_len = if self.transport.loop_config.enabled { - self.transport.loop_config.synth_length as usize + let synth_a_loop_len = if self.transport.loop_config.enabled { + self.transport.loop_config.synth_a_length as usize + } else { + crate::sequencer::synth_pattern::MAX_STEPS + }; + let synth_b_loop_len = if self.transport.loop_config.enabled { + self.transport.loop_config.synth_b_length as usize } else { crate::sequencer::synth_pattern::MAX_STEPS }; @@ -259,7 +309,8 @@ impl AudioEngine { ) { // Map free-running global_step into per-instrument pattern positions let drum_step = event.global_step % drum_loop_len.max(1); - let synth_step = event.global_step % synth_loop_len.max(1); + let synth_a_step = event.global_step % synth_a_loop_len.max(1); + let synth_b_step = event.global_step % synth_b_loop_len.max(1); let pattern_step = drum_step; // Trigger drum voices for active steps @@ -279,38 +330,62 @@ impl AudioEngine { } } - // Trigger synth voice for active steps (with multi-step note length) - let mut synth_triggered = false; - let synth_step_data = &self.synth_pattern.steps[synth_step]; let samples_per_step = (self.sample_rate * 60.0 / self.transport.bpm / 4.0) as u32; - if synth_step_data.is_active() && !self.synth_pattern.params.mute { - // New active note always takes priority (re-trigger) - self.synth_voice.trigger(&self.synth_pattern.params, synth_step_data.note); - synth_triggered = true; - - let length = (synth_step_data.length as usize).max(1); - if length <= 1 { - // Single-step note: gate ~75% of one step - self.synth_gate_samples = samples_per_step * 3 / 4; - self.synth_note_end_step = None; - } else { - // Multi-step note: hold for full duration minus small release window - let end_step = (synth_step + length - 1).min(synth_loop_len.max(1) - 1); - self.synth_gate_samples = samples_per_step * length as u32 - samples_per_step / 4; - self.synth_note_end_step = Some(end_step); + // --- Synth A: trigger voice for active steps (with multi-step note length) --- + let mut synth_a_triggered = false; + { + let step_data = &self.synth_a.pattern.steps[synth_a_step]; + if step_data.is_active() && !self.synth_a.pattern.params.mute { + self.synth_a.voice.trigger(&self.synth_a.pattern.params, step_data.note); + synth_a_triggered = true; + + let length = (step_data.length as usize).max(1); + if length <= 1 { + self.synth_a.gate_samples = samples_per_step * 3 / 4; + self.synth_a.note_end_step = None; + } else { + let end_step = (synth_a_step + length - 1).min(synth_a_loop_len.max(1) - 1); + self.synth_a.gate_samples = samples_per_step * length as u32 - samples_per_step / 4; + self.synth_a.note_end_step = Some(end_step); + } + } else if let Some(end) = self.synth_a.note_end_step { + if synth_a_step == end { + self.synth_a.gate_samples = samples_per_step * 3 / 4; + self.synth_a.note_end_step = None; + } + } else if !step_data.is_active() && self.synth_a.gate_samples > 0 { + self.synth_a.voice.release(); + self.synth_a.gate_samples = 0; } - } else if let Some(end) = self.synth_note_end_step { - if synth_step == end { - // Last step of a long note — set gate to expire at ~75% of this step - self.synth_gate_samples = samples_per_step * 3 / 4; - self.synth_note_end_step = None; + } + + // --- Synth B: trigger voice for active steps (with multi-step note length) --- + let mut synth_b_triggered = false; + { + let step_data = &self.synth_b.pattern.steps[synth_b_step]; + if step_data.is_active() && !self.synth_b.pattern.params.mute { + self.synth_b.voice.trigger(&self.synth_b.pattern.params, step_data.note); + synth_b_triggered = true; + + let length = (step_data.length as usize).max(1); + if length <= 1 { + self.synth_b.gate_samples = samples_per_step * 3 / 4; + self.synth_b.note_end_step = None; + } else { + let end_step = (synth_b_step + length - 1).min(synth_b_loop_len.max(1) - 1); + self.synth_b.gate_samples = samples_per_step * length as u32 - samples_per_step / 4; + self.synth_b.note_end_step = Some(end_step); + } + } else if let Some(end) = self.synth_b.note_end_step { + if synth_b_step == end { + self.synth_b.gate_samples = samples_per_step * 3 / 4; + self.synth_b.note_end_step = None; + } + } else if !step_data.is_active() && self.synth_b.gate_samples > 0 { + self.synth_b.voice.release(); + self.synth_b.gate_samples = 0; } - // Otherwise we're in the middle of a long note — do nothing, let gate continue - } else if !synth_step_data.is_active() && self.synth_gate_samples > 0 { - // No note on this step and not covered by a long note — release - self.synth_voice.release(); - self.synth_gate_samples = 0; } // Send playback position to UI (drop if channel is full) @@ -319,18 +394,26 @@ impl AudioEngine { beat: event.beat, is_bar_start: event.is_bar_start, triggered, - synth_triggered, + synth_a_triggered, drum_step, - synth_step, + synth_a_step, + synth_b_step, + synth_b_triggered, }); } } - // Decrement synth gate counter and release when expired - if self.synth_gate_samples > 0 { - self.synth_gate_samples -= 1; - if self.synth_gate_samples == 0 { - self.synth_voice.release(); + // Decrement synth gate counters and release when expired + if self.synth_a.gate_samples > 0 { + self.synth_a.gate_samples -= 1; + if self.synth_a.gate_samples == 0 { + self.synth_a.voice.release(); + } + } + if self.synth_b.gate_samples > 0 { + self.synth_b.gate_samples -= 1; + if self.synth_b.gate_samples == 0 { + self.synth_b.voice.release(); } } @@ -367,42 +450,79 @@ impl AudioEngine { reverb_send *= drum_vol; delay_send *= drum_vol; - // Generate synth audio with LFO modulation + per-instrument saturator - let synth_params = &self.synth_pattern.params; - let mut modulated_params = *synth_params; - if synth_params.lfo_depth > 0.001 { - let div_mult = lfo_division_multiplier(synth_params.lfo_division); - let lfo_val = self.lfo.tick( - self.sample_rate, - self.transport.bpm, - div_mult, - synth_params.lfo_waveform, - ); - let mod_amount = lfo_val * synth_params.lfo_depth; - let dest_idx = synth_params.lfo_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); + // --- Generate synth A audio with LFO modulation + per-instrument FX --- + let synth_a_out = { + let synth_params = &self.synth_a.pattern.params; + let mut modulated_params = *synth_params; + if synth_params.lfo_depth > 0.001 { + let div_mult = lfo_division_multiplier(synth_params.lfo_division); + let lfo_val = self.synth_a.lfo.tick( + self.sample_rate, + self.transport.bpm, + div_mult, + synth_params.lfo_waveform, + ); + let mod_amount = lfo_val * synth_params.lfo_depth; + let dest_idx = synth_params.lfo_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_voice.tick(&modulated_params); - let mut synth_dry: f32 = 0.0; - if !self.synth_pattern.params.mute { - // tick() already applies params.volume, don't double-apply - synth_dry = synth_sample; - reverb_send += synth_sample * self.synth_pattern.params.send_reverb; - delay_send += synth_sample * self.synth_pattern.params.send_delay; - } - let synth_sat = self.synth_saturator.tick(synth_dry); + let synth_sample = self.synth_a.voice.tick(&modulated_params); + let mut synth_dry: f32 = 0.0; + if !self.synth_a.pattern.params.mute { + synth_dry = synth_sample; + reverb_send += synth_sample * self.synth_a.pattern.params.send_reverb; + delay_send += synth_sample * self.synth_a.pattern.params.send_delay; + } + let sa_sat = self.synth_a.saturator.tick(synth_dry); + let sa_reverb = self.synth_a.reverb.tick(sa_sat * self.synth_a.pattern.params.send_reverb); + let sa_delay = self.synth_a.delay.tick(sa_sat * self.synth_a.pattern.params.send_delay); + sa_sat + sa_reverb + sa_delay + }; + + // --- Generate synth B audio with LFO modulation + per-instrument FX --- + let synth_b_out = { + let synth_params = &self.synth_b.pattern.params; + let mut modulated_params = *synth_params; + if synth_params.lfo_depth > 0.001 { + let div_mult = lfo_division_multiplier(synth_params.lfo_division); + let lfo_val = self.synth_b.lfo.tick( + self.sample_rate, + self.transport.bpm, + div_mult, + synth_params.lfo_waveform, + ); + let mod_amount = lfo_val * synth_params.lfo_depth; + let dest_idx = synth_params.lfo_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 { + synth_dry = synth_sample; + reverb_send += synth_sample * self.synth_b.pattern.params.send_reverb; + delay_send += synth_sample * self.synth_b.pattern.params.send_delay; + } + let sb_sat = self.synth_b.saturator.tick(synth_dry); + let sb_reverb = self.synth_b.reverb.tick(sb_sat * self.synth_b.pattern.params.send_reverb); + let sb_delay = self.synth_b.delay.tick(sb_sat * self.synth_b.pattern.params.send_delay); + sb_sat + sb_reverb + sb_delay + }; - // Process send effects - let reverb_out = self.reverb.tick(reverb_send); - let delay_out = self.delay.tick(delay_send); + // Process send effects (drum bus) + let reverb_out = self.drum_reverb.tick(reverb_send); + let delay_out = self.drum_delay.tick(delay_send); // Mix: per-instrument saturated signals + wet effects → headroom → master volume → compressor → clip - // Synth + effects are mono, centered to both channels - let mono_wet = synth_sat + reverb_out + delay_out; + // Both synths centered (mono to both channels) + let mono_wet = synth_a_out + synth_b_out + reverb_out + delay_out; let mixed_l = (drum_sat_l + mono_wet) * 0.5 * self.master_volume; let mixed_r = (drum_sat_r + mono_wet) * 0.5 * self.master_volume; // Linked stereo compression: detect from mono sum, apply gain to both channels diff --git a/src/keys.rs b/src/keys.rs index f52364b..8a22527 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -4,7 +4,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::app::{App, DrumControlField, FocusSection, ModalAction, ModalState}; use crate::presets::{PatternMergeMode, PresetTarget}; -use crate::messages::UiToAudio; +use crate::messages::{SynthId, UiToAudio}; use crate::sequencer::drum_pattern::{MAX_STEPS, NUM_DRUM_TRACKS, TRACK_IDS}; use crate::sequencer::project::{NUM_KITS, NUM_PATTERNS}; use crate::sequencer::synth_pattern::{SynthControlField, MAX_STEPS as SYNTH_MAX_STEPS}; @@ -53,6 +53,37 @@ fn kit_key_to_index(ch: char) -> Option { } } +/// Determine which synth (A or B) is targeted by the current focus section. +/// Returns None if focus is on drums or transport. +fn focused_synth(focus: FocusSection) -> Option { + match focus { + FocusSection::SynthAGrid | FocusSection::SynthAControls => Some(SynthId::A), + FocusSection::SynthBGrid | FocusSection::SynthBControls => Some(SynthId::B), + _ => None, + } +} + +/// Get mutable reference to the SynthUiState for the given synth. +fn synth_ui_mut(app: &mut App, synth_id: SynthId) -> &mut crate::app::SynthUiState { + match synth_id { + SynthId::A => &mut app.ui.synth_a, + SynthId::B => &mut app.ui.synth_b, + } +} + +/// Get mutable references to both the SynthUiState and SynthPattern for the given synth. +fn synth_ui_and_pattern(app: &mut App, synth_id: SynthId) -> (&mut crate::app::SynthUiState, &mut crate::sequencer::synth_pattern::SynthPattern) { + match synth_id { + SynthId::A => (&mut app.ui.synth_a, &mut app.synth_a_pattern), + SynthId::B => (&mut app.ui.synth_b, &mut app.synth_b_pattern), + } +} + +/// Send the appropriate synth pattern to the audio thread. +fn send_synth(app: &App, synth_id: SynthId) { + app.send_synth_pattern(synth_id); +} + /// Main key event handler — dispatches based on modal state first. pub fn handle_key(app: &mut App, key: KeyEvent) { // Modal dialogs take priority @@ -156,19 +187,27 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { return; } - // Synth section collapse/expand toggle + // Synth section collapse/expand toggle (bulk toggle all synth panels) KeyCode::F(2) => { - app.ui.synth_collapsed = !app.ui.synth_collapsed; + let all_synth_visible = app.ui.panel_vis.synth_a_knobs + && app.ui.panel_vis.synth_a_grid + && app.ui.panel_vis.synth_b_knobs + && app.ui.panel_vis.synth_b_grid; + let new_state = !all_synth_visible; + app.ui.panel_vis.synth_a_knobs = new_state; + app.ui.panel_vis.synth_a_grid = new_state; + app.ui.panel_vis.synth_b_knobs = new_state; + app.ui.panel_vis.synth_b_grid = new_state; return; } // Focus navigation KeyCode::Tab if !key.modifiers.contains(KeyModifiers::SHIFT) => { - app.ui.focus = app.ui.focus.next(); + app.ui.focus = app.ui.focus.next(&app.ui.panel_vis); return; } KeyCode::BackTab => { - app.ui.focus = app.ui.focus.prev(); + app.ui.focus = app.ui.focus.prev(&app.ui.panel_vis); return; } @@ -253,15 +292,15 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { // Loop length cycle (Shift+L) — focus-aware KeyCode::Char('L') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); - if is_synth { - app.transport.loop_config.synth_length = match app.transport.loop_config.synth_length { - 8 => 16, - 16 => 24, - 24 => 32, - _ => 8, + if let Some(synth_id) = focused_synth(app.ui.focus) { + let len_ref = match synth_id { + SynthId::A => &mut app.transport.loop_config.synth_a_length, + SynthId::B => &mut app.transport.loop_config.synth_b_length, }; - app.show_status(format!("Synth loop: {} steps", app.transport.loop_config.synth_length)); + *len_ref = match *len_ref { 8 => 16, 16 => 24, 24 => 32, _ => 8 }; + let new_len = *len_ref; + let label = match synth_id { SynthId::A => "Synth A", SynthId::B => "Synth B" }; + app.show_status(format!("{} loop: {} steps", label, new_len)); } else { app.transport.loop_config.drum_length = match app.transport.loop_config.drum_length { 8 => 16, @@ -353,7 +392,7 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { // Tube saturator: Shift+T cycles presets (Off → Warm → Hot → Crispy → Off) — focus-aware KeyCode::Char('T') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); + let is_synth = focused_synth(app.ui.focus).is_some(); let cur = if is_synth { app.effect_params.synth_saturator_drive } else { @@ -401,31 +440,49 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { // Pattern prev/next: [ ] queued, { } immediate — focus-aware KeyCode::Char('[') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); - let cur = if is_synth { app.ui.synth_active_pattern } else { app.ui.active_pattern }; - let prev = if cur == 0 { NUM_PATTERNS - 1 } else { cur - 1 }; - if is_synth { app.queue_synth_pattern(prev); } else { app.queue_pattern(prev); } + if let Some(synth_id) = focused_synth(app.ui.focus) { + let ui = match synth_id { SynthId::A => &app.ui.synth_a, SynthId::B => &app.ui.synth_b }; + let prev = if ui.active_pattern == 0 { NUM_PATTERNS - 1 } else { ui.active_pattern - 1 }; + app.queue_synth_pattern_for(synth_id, prev); + } else { + let cur = app.ui.active_pattern; + let prev = if cur == 0 { NUM_PATTERNS - 1 } else { cur - 1 }; + app.queue_pattern(prev); + } return; } KeyCode::Char(']') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); - let cur = if is_synth { app.ui.synth_active_pattern } else { app.ui.active_pattern }; - let next = (cur + 1) % NUM_PATTERNS; - if is_synth { app.queue_synth_pattern(next); } else { app.queue_pattern(next); } + if let Some(synth_id) = focused_synth(app.ui.focus) { + let ui = match synth_id { SynthId::A => &app.ui.synth_a, SynthId::B => &app.ui.synth_b }; + let next = (ui.active_pattern + 1) % NUM_PATTERNS; + app.queue_synth_pattern_for(synth_id, next); + } else { + let next = (app.ui.active_pattern + 1) % NUM_PATTERNS; + app.queue_pattern(next); + } return; } KeyCode::Char('{') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); - let cur = if is_synth { app.ui.synth_active_pattern } else { app.ui.active_pattern }; - let prev = if cur == 0 { NUM_PATTERNS - 1 } else { cur - 1 }; - if is_synth { app.switch_synth_pattern(prev); } else { app.switch_pattern(prev); } + if let Some(synth_id) = focused_synth(app.ui.focus) { + let ui = match synth_id { SynthId::A => &app.ui.synth_a, SynthId::B => &app.ui.synth_b }; + let prev = if ui.active_pattern == 0 { NUM_PATTERNS - 1 } else { ui.active_pattern - 1 }; + app.switch_synth_pattern_for(synth_id, prev); + } else { + let cur = app.ui.active_pattern; + let prev = if cur == 0 { NUM_PATTERNS - 1 } else { cur - 1 }; + app.switch_pattern(prev); + } return; } KeyCode::Char('}') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); - let cur = if is_synth { app.ui.synth_active_pattern } else { app.ui.active_pattern }; - let next = (cur + 1) % NUM_PATTERNS; - if is_synth { app.switch_synth_pattern(next); } else { app.switch_pattern(next); } + if let Some(synth_id) = focused_synth(app.ui.focus) { + let ui = match synth_id { SynthId::A => &app.ui.synth_a, SynthId::B => &app.ui.synth_b }; + let next = (ui.active_pattern + 1) % NUM_PATTERNS; + app.switch_synth_pattern_for(synth_id, next); + } else { + let next = (app.ui.active_pattern + 1) % NUM_PATTERNS; + app.switch_pattern(next); + } return; } @@ -436,9 +493,8 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { if let KeyCode::Char(ch) = key.code { if let Some(idx) = pattern_key_to_index(ch) { let is_shift = key.modifiers.contains(KeyModifiers::SHIFT); - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); - if is_synth { - if is_shift { app.switch_synth_pattern(idx); } else { app.queue_synth_pattern(idx); } + if let Some(synth_id) = focused_synth(app.ui.focus) { + if is_shift { app.switch_synth_pattern_for(synth_id, idx); } else { app.queue_synth_pattern_for(synth_id, idx); } } else { if is_shift { app.switch_pattern(idx); } else { app.queue_pattern(idx); } } @@ -450,8 +506,11 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { if let KeyCode::Char(ch) = key.code { if let Some(idx) = kit_key_to_index(ch) { if idx < NUM_KITS { - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); - if is_synth { app.switch_synth_kit(idx); } else { app.switch_kit(idx); } + if let Some(synth_id) = focused_synth(app.ui.focus) { + app.switch_synth_kit_for(synth_id, idx); + } else { + app.switch_kit(idx); + } } return; } @@ -460,22 +519,32 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { // ── Drum pad keys / Synth note keys (ZXCVBNM,) ───────────────────── if let KeyCode::Char(ch) = key.code { // When synth grid/controls is focused, use as chromatic keyboard - if matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls) { + if let Some(synth_id) = focused_synth(app.ui.focus) { if let Some(semitone) = synth_key_to_semitone(ch) { - let note = (app.ui.synth_octave * 12 + semitone).min(127); + let (ui, pattern) = match synth_id { + SynthId::A => (&mut app.ui.synth_a, &mut app.synth_a_pattern), + SynthId::B => (&mut app.ui.synth_b, &mut app.synth_b_pattern), + }; + let note = (ui.octave * 12 + semitone).min(127); // Trigger synth sound - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(note)); - app.ui.synth_flash = 6; + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(synth_id, note)); + ui.flash = 6; // If on synth grid, write note at cursor - if app.ui.focus == FocusSection::SynthGrid { - let s = app.ui.synth_cursor_step; - app.synth_pattern.steps[s].note = note; - app.synth_pattern.steps[s].velocity = 100; - app.send_synth_pattern(); + let is_grid = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthBGrid); + if is_grid { + let s = ui.cursor_step; + pattern.steps[s].note = note; + pattern.steps[s].velocity = 100; + app.send_synth_pattern(synth_id); app.dirty = true; // Advance cursor - app.ui.synth_cursor_step = (app.ui.synth_cursor_step + 1) % SYNTH_MAX_STEPS; + // Re-borrow to avoid conflict — we already wrote above + let ui2 = match synth_id { + SynthId::A => &mut app.ui.synth_a, + SynthId::B => &mut app.ui.synth_b, + }; + ui2.cursor_step = (ui2.cursor_step + 1) % SYNTH_MAX_STEPS; } // If recording + playing, write at playhead @@ -484,9 +553,13 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { { let step = app.ui.playback_step; if step < SYNTH_MAX_STEPS { - app.synth_pattern.steps[step].note = note; - app.synth_pattern.steps[step].velocity = 100; - app.send_synth_pattern(); + let pattern = match synth_id { + SynthId::A => &mut app.synth_a_pattern, + SynthId::B => &mut app.synth_b_pattern, + }; + pattern.steps[step].note = note; + pattern.steps[step].velocity = 100; + app.send_synth_pattern(synth_id); app.dirty = true; } } @@ -520,8 +593,10 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { match app.ui.focus { FocusSection::DrumGrid => handle_drum_grid(app, key), FocusSection::Knobs => handle_knobs(app, key), - FocusSection::SynthGrid => handle_synth_grid(app, key), - FocusSection::SynthControls => handle_synth_controls(app, key), + FocusSection::SynthAGrid => handle_synth_grid(app, key, SynthId::A), + FocusSection::SynthAControls => handle_synth_controls(app, key, SynthId::A), + FocusSection::SynthBGrid => handle_synth_grid(app, key, SynthId::B), + FocusSection::SynthBControls => handle_synth_controls(app, key, SynthId::B), FocusSection::Transport => {} // transport keys are all global } } @@ -733,100 +808,124 @@ fn handle_knobs(app: &mut App, key: KeyEvent) { // ── Synth Grid ───────────────────────────────────────────────────────────── -fn handle_synth_grid(app: &mut App, key: KeyEvent) { +fn handle_synth_grid(app: &mut App, key: KeyEvent, synth_id: SynthId) { + // Helper macro-like closures aren't ideal; use direct match for each synth + let controls_focus = match synth_id { + SynthId::A => FocusSection::SynthAControls, + SynthId::B => FocusSection::SynthBControls, + }; + let label = match synth_id { SynthId::A => "Synth A", SynthId::B => "Synth B" }; + match key.code { // Shift+Left: decrease note length KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => { - let s = app.ui.synth_cursor_step; - if app.synth_pattern.steps[s].is_active() && app.synth_pattern.steps[s].length > 1 { - app.synth_pattern.steps[s].length -= 1; - app.send_synth_pattern(); + let (ui, pattern) = synth_ui_and_pattern(app, synth_id); + let s = ui.cursor_step; + if pattern.steps[s].is_active() && pattern.steps[s].length > 1 { + pattern.steps[s].length -= 1; + send_synth(app, synth_id); app.dirty = true; } } // Shift+Right: increase note length KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => { - let s = app.ui.synth_cursor_step; - if app.synth_pattern.steps[s].is_active() { - let loop_len = app.transport.loop_config.synth_length as usize; + let loop_len = match synth_id { + SynthId::A => app.transport.loop_config.synth_a_length as usize, + SynthId::B => app.transport.loop_config.synth_b_length as usize, + }; + let (ui, pattern) = synth_ui_and_pattern(app, synth_id); + let s = ui.cursor_step; + if pattern.steps[s].is_active() { let max_length = (loop_len - s).min(32) as u8; - if app.synth_pattern.steps[s].length < max_length { - app.synth_pattern.steps[s].length += 1; - app.send_synth_pattern(); + if pattern.steps[s].length < max_length { + pattern.steps[s].length += 1; + send_synth(app, synth_id); app.dirty = true; } } } KeyCode::Left => { - app.ui.synth_cursor_step = if app.ui.synth_cursor_step == 0 { + let ui = synth_ui_mut(app, synth_id); + ui.cursor_step = if ui.cursor_step == 0 { SYNTH_MAX_STEPS - 1 } else { - app.ui.synth_cursor_step - 1 + ui.cursor_step - 1 }; } KeyCode::Right => { - if app.ui.synth_cursor_step == SYNTH_MAX_STEPS - 1 { + let ui = synth_ui_mut(app, synth_id); + if ui.cursor_step == SYNTH_MAX_STEPS - 1 { // Move into synth controls - app.ui.focus = FocusSection::SynthControls; + app.ui.focus = controls_focus; } else { - app.ui.synth_cursor_step += 1; + let ui = synth_ui_mut(app, synth_id); + ui.cursor_step += 1; } } KeyCode::Up => { // Change note pitch up (semitone), or Shift for octave - let s = app.ui.synth_cursor_step; - if app.synth_pattern.steps[s].is_active() { + let (ui, pattern) = synth_ui_and_pattern(app, synth_id); + let s = ui.cursor_step; + if pattern.steps[s].is_active() { let delta = if key.modifiers.contains(KeyModifiers::SHIFT) { 12 } else { 1 }; - app.synth_pattern.steps[s].note = (app.synth_pattern.steps[s].note + delta).min(127); - let note = app.synth_pattern.steps[s].note; - app.send_synth_pattern(); + pattern.steps[s].note = (pattern.steps[s].note + delta).min(127); + let note = pattern.steps[s].note; + ui.flash = 6; + send_synth(app, synth_id); app.dirty = true; - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(note)); - app.ui.synth_flash = 6; + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(synth_id, note)); } } KeyCode::Down => { - let s = app.ui.synth_cursor_step; - if app.synth_pattern.steps[s].is_active() { + let (ui, pattern) = synth_ui_and_pattern(app, synth_id); + let s = ui.cursor_step; + if pattern.steps[s].is_active() { let delta = if key.modifiers.contains(KeyModifiers::SHIFT) { 12 } else { 1 }; - app.synth_pattern.steps[s].note = app.synth_pattern.steps[s].note.saturating_sub(delta).max(12); - let note = app.synth_pattern.steps[s].note; - app.send_synth_pattern(); + pattern.steps[s].note = pattern.steps[s].note.saturating_sub(delta).max(12); + let note = pattern.steps[s].note; + ui.flash = 6; + send_synth(app, synth_id); app.dirty = true; - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(note)); - app.ui.synth_flash = 6; + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(synth_id, note)); } } KeyCode::Enter => { - let s = app.ui.synth_cursor_step; - let step = &mut app.synth_pattern.steps[s]; + let (ui, pattern) = synth_ui_and_pattern(app, synth_id); + let s = ui.cursor_step; + let octave = ui.octave; + let step = &mut pattern.steps[s]; if step.is_active() { // Toggle off — reset length step.velocity = 0; step.length = 1; } else { // Toggle on with default note at current octave - step.note = app.ui.synth_octave * 12 + 12; // C at current octave + step.note = octave * 12 + 12; // C at current octave step.velocity = 100; step.length = 1; } - app.send_synth_pattern(); + send_synth(app, synth_id); app.dirty = true; // Advance cursor - app.ui.synth_cursor_step = (app.ui.synth_cursor_step + 1) % SYNTH_MAX_STEPS; + let ui = synth_ui_mut(app, synth_id); + ui.cursor_step = (ui.cursor_step + 1) % SYNTH_MAX_STEPS; } KeyCode::Char('(') => { // Octave down - if app.ui.synth_octave > 0 { - app.ui.synth_octave -= 1; - app.show_status(format!("Synth octave: {}", app.ui.synth_octave)); + let ui = synth_ui_mut(app, synth_id); + if ui.octave > 0 { + ui.octave -= 1; + let oct = ui.octave; + app.show_status(format!("{} octave: {}", label, oct)); } } KeyCode::Char(')') => { // Octave up - if app.ui.synth_octave < 8 { - app.ui.synth_octave += 1; - app.show_status(format!("Synth octave: {}", app.ui.synth_octave)); + let ui = synth_ui_mut(app, synth_id); + if ui.octave < 8 { + ui.octave += 1; + let oct = ui.octave; + app.show_status(format!("{} octave: {}", label, oct)); } } _ => {} @@ -870,65 +969,74 @@ fn find_synth_field_pos(field: SynthControlField) -> (usize, usize) { (0, 0) } -fn handle_synth_controls(app: &mut App, key: KeyEvent) { +fn handle_synth_controls(app: &mut App, key: KeyEvent, synth_id: SynthId) { let has_shift = key.modifiers.contains(KeyModifiers::SHIFT); let has_alt = key.modifiers.contains(KeyModifiers::ALT); match key.code { KeyCode::Left => { - let (r, c) = find_synth_field_pos(app.ui.synth_ctrl_field); + let ui = synth_ui_mut(app, synth_id); + let (r, c) = find_synth_field_pos(ui.ctrl_field); if c > 0 { - app.ui.synth_ctrl_field = SYNTH_CTRL_ROWS[r][c - 1]; + ui.ctrl_field = SYNTH_CTRL_ROWS[r][c - 1]; } - // At leftmost field: do nothing (no cross-box nav) } KeyCode::Right => { - let (r, c) = find_synth_field_pos(app.ui.synth_ctrl_field); + let ui = synth_ui_mut(app, synth_id); + let (r, c) = find_synth_field_pos(ui.ctrl_field); if c + 1 < SYNTH_CTRL_ROWS[r].len() { - app.ui.synth_ctrl_field = SYNTH_CTRL_ROWS[r][c + 1]; + ui.ctrl_field = SYNTH_CTRL_ROWS[r][c + 1]; } - // At rightmost field: do nothing (no cross-box nav) } KeyCode::Up if has_shift || has_alt => { - adjust_synth_field(app, PARAM_INCREMENT); + adjust_synth_field(app, synth_id, PARAM_INCREMENT); if has_alt { - let note = app.ui.synth_octave * 12 + 12; - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(note)); - app.ui.synth_flash = 6; + let ui = synth_ui_mut(app, synth_id); + let note = ui.octave * 12 + 12; + ui.flash = 6; + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(synth_id, note)); } } KeyCode::Down if has_shift || has_alt => { - adjust_synth_field(app, -PARAM_INCREMENT); + adjust_synth_field(app, synth_id, -PARAM_INCREMENT); if has_alt { - let note = app.ui.synth_octave * 12 + 12; - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(note)); - app.ui.synth_flash = 6; + let ui = synth_ui_mut(app, synth_id); + let note = ui.octave * 12 + 12; + ui.flash = 6; + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(synth_id, note)); } } KeyCode::Up => { - let (r, c) = find_synth_field_pos(app.ui.synth_ctrl_field); + let ui = synth_ui_mut(app, synth_id); + let (r, c) = find_synth_field_pos(ui.ctrl_field); if r > 0 { let new_row = &SYNTH_CTRL_ROWS[r - 1]; let new_c = c.min(new_row.len() - 1); - app.ui.synth_ctrl_field = new_row[new_c]; + ui.ctrl_field = new_row[new_c]; } } KeyCode::Down => { - let (r, c) = find_synth_field_pos(app.ui.synth_ctrl_field); + let ui = synth_ui_mut(app, synth_id); + let (r, c) = find_synth_field_pos(ui.ctrl_field); if r + 1 < SYNTH_CTRL_ROWS.len() { let new_row = &SYNTH_CTRL_ROWS[r + 1]; let new_c = c.min(new_row.len() - 1); - app.ui.synth_ctrl_field = new_row[new_c]; + ui.ctrl_field = new_row[new_c]; } } _ => {} } } -fn adjust_synth_field(app: &mut App, delta: f32) { - let field = app.ui.synth_ctrl_field; +fn adjust_synth_field(app: &mut App, synth_id: SynthId, delta: f32) { + let ui = synth_ui_mut(app, synth_id); + let field = ui.ctrl_field; + let pattern = match synth_id { + SynthId::A => &mut app.synth_a_pattern, + SynthId::B => &mut app.synth_b_pattern, + }; if field == SynthControlField::Mute { - app.synth_pattern.params.mute = !app.synth_pattern.params.mute; + pattern.params.mute = !pattern.params.mute; } else if field.is_enum() { let max_val: u8 = match field { SynthControlField::FilterType => 2, @@ -937,19 +1045,19 @@ fn adjust_synth_field(app: &mut App, delta: f32) { SynthControlField::LfoDest => (crate::sequencer::synth_pattern::LFO_DEST_FIELDS.len() - 1) as u8, _ => 3, // Osc1/Osc2 waveforms }; - let cur = field.get(&app.synth_pattern.params); + let cur = field.get(&pattern.params); let cur_int = (cur * max_val as f32).round() as u8; let new_int = if delta > 0.0 { (cur_int + 1).min(max_val) } else { cur_int.saturating_sub(1) }; - field.set(&mut app.synth_pattern.params, new_int as f32 / max_val as f32); + field.set(&mut pattern.params, new_int as f32 / max_val as f32); } else { - let cur = field.get(&app.synth_pattern.params); - field.set(&mut app.synth_pattern.params, cur + delta); + let cur = field.get(&pattern.params); + field.set(&mut pattern.params, cur + delta); } - app.send_synth_pattern(); + send_synth(app, synth_id); app.dirty = true; } @@ -1053,7 +1161,8 @@ fn handle_preset_browser(app: &mut App, key: KeyEvent) { PresetTarget::SynthSound => { if let Some(params) = browser.selected_synth_params() { let name = browser.preset_names.get(browser.preset_idx).copied().unwrap_or("?"); - app.apply_synth_preset(params); + let synth_id = browser.target_synth; + app.apply_synth_preset(synth_id, params); app.ui.modal = ModalState::None; app.show_status(format!("Loaded: {}", name)); } @@ -1117,7 +1226,8 @@ fn handle_pattern_browser(app: &mut App, key: KeyEvent) { app.show_status(format!("{}: {}", mode_label, name)); } else if let Some(preset) = pb.browser.selected_synth_pattern() { let name = preset.name; - app.apply_synth_pattern_preset(&preset.steps, merge); + let synth_id = pb.browser.target_synth; + app.apply_synth_pattern_preset(synth_id, &preset.steps, merge); app.show_status(format!("{}: {}", mode_label, name)); } } @@ -1148,10 +1258,15 @@ fn preview_preset(app: &mut App) { } PresetTarget::SynthSound => { if let Some(params) = browser.selected_synth_params() { - app.apply_synth_preset(params); - let note = app.ui.synth_octave * 12 + 12; - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(note)); - app.ui.synth_flash = 6; + let synth_id = browser.target_synth; + app.apply_synth_preset(synth_id, params); + let (octave, flash) = match synth_id { + SynthId::A => (app.ui.synth_a.octave, &mut app.ui.synth_a.flash), + SynthId::B => (app.ui.synth_b.octave, &mut app.ui.synth_b.flash), + }; + let note = octave * 12 + 12; + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(synth_id, note)); + *flash = 6; } } PresetTarget::Pattern | PresetTarget::SynthPattern => {} // no preview for patterns diff --git a/src/messages.rs b/src/messages.rs index d141bde..ea031ea 100644 --- a/src/messages.rs +++ b/src/messages.rs @@ -5,16 +5,22 @@ use crate::sequencer::drum_pattern::{DrumPattern, DrumTrackId}; use crate::sequencer::synth_pattern::SynthPattern; use crate::sequencer::transport::Transport; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SynthId { + A, + B, +} + /// Commands sent from the UI thread to the audio thread. /// Sent via a bounded crossbeam channel (capacity 64). pub enum UiToAudio { SetTransport(Transport), SetDrumPattern(DrumPattern), - SetSynthPattern(SynthPattern), + SetSynthPattern(SynthId, SynthPattern), SetEffectParams(EffectParams), TriggerDrum(DrumTrackId), // fire the voice immediately - TriggerSynth(u8), // MIDI note number — fire synth immediately - ReleaseSynth, // release synth envelopes + TriggerSynth(SynthId, u8), // MIDI note number — fire synth immediately + ReleaseSynth(SynthId), // release synth envelopes } /// Notifications sent from the audio thread back to the UI. @@ -25,8 +31,10 @@ pub enum AudioToUi { beat: u8, is_bar_start: bool, triggered: u8, // bitmask: which drum tracks triggered on this step - synth_triggered: bool, // whether synth was triggered on this step + synth_a_triggered: bool, // whether synth A was triggered on this step drum_step: usize, // drum pattern step (global_step % drum_length) - synth_step: usize, // synth pattern step (global_step % synth_length) + synth_a_step: usize, // synth A pattern step (global_step % synth_length) + synth_b_step: usize, // synth B pattern step (global_step % synth_length) + synth_b_triggered: bool, // whether synth B was triggered on this step }, } diff --git a/src/mouse.rs b/src/mouse.rs index d8a041a..6504dc3 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -7,10 +7,10 @@ use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use crate::app::{App, CompressorDrag, DragState, DrumControlField, FocusSection, KNOB_FIELDS, ModalState, SynthDrag, SynthNoteDrag}; -use crate::messages::UiToAudio; +use crate::messages::{SynthId, UiToAudio}; use crate::sequencer::drum_pattern::{NUM_DRUM_TRACKS, TRACK_IDS}; use crate::sequencer::project::{NUM_KITS, NUM_PATTERNS}; -use crate::ui::layout::compute_layout; +use crate::ui::layout::{compute_dual_layout, DualSynthLayout}; /// Threshold for double-click detection. const DOUBLE_CLICK_MS: u128 = 300; @@ -40,7 +40,7 @@ pub fn handle_mouse(app: &mut App, event: MouseEvent, term_size: Rect) { MouseEventKind::Up(MouseButton::Left) => { // If synth note drag ended without movement, toggle the step if let Some(ref drag) = app.ui.mouse.synth_note_drag { - if app.synth_pattern.steps[drag.step].length == drag.original_length { + if app.synth_a_pattern.steps[drag.step].length == drag.original_length { // No length change — treat as double-click toggle on second click // (first click just selects; this is handled by last_click logic) } @@ -60,9 +60,9 @@ pub fn handle_mouse(app: &mut App, event: MouseEvent, term_size: Rect) { } fn handle_scroll(app: &mut App, col: u16, row: u16, delta: f32, term_size: Rect) { - let ly = compute_layout(term_size, app.ui.synth_collapsed, app.ui.show_help, app.ui.show_waveform); + let ly = compute_dual_layout(term_size, &app.ui.panel_vis); - if hit_test_area(col, row, ly.knobs) { + if hit_test_area(col, row, ly.drum_knobs) { // Scroll over drum knobs: adjust currently selected drum param let track = app.ui.drum_ctrl_track; let field = app.ui.drum_ctrl_field; @@ -70,13 +70,22 @@ fn handle_scroll(app: &mut App, col: u16, row: u16, delta: f32, term_size: Rect) set_param_value(&mut app.drum_pattern.params[track], field, current + delta); app.send_drum_pattern(); app.dirty = true; - } else if hit_test_area(col, row, ly.synth_knobs) { - // Scroll over synth knobs: adjust currently selected synth param - let field = app.ui.synth_ctrl_field; + } else if hit_test_area(col, row, ly.synth_a_knobs) { + // Scroll over synth A knobs + let field = app.ui.synth_a.ctrl_field; if !field.is_enum() { - let current = field.get(&app.synth_pattern.params); - field.set(&mut app.synth_pattern.params, (current + delta).clamp(0.0, 1.0)); - app.send_synth_pattern(); + let current = field.get(&app.synth_a_pattern.params); + field.set(&mut app.synth_a_pattern.params, (current + delta).clamp(0.0, 1.0)); + app.send_synth_pattern(SynthId::A); + app.dirty = true; + } + } else if hit_test_area(col, row, ly.synth_b_knobs) { + // Scroll over synth B knobs + let field = app.ui.synth_b.ctrl_field; + if !field.is_enum() { + let current = field.get(&app.synth_b_pattern.params); + field.set(&mut app.synth_b_pattern.params, (current + delta).clamp(0.0, 1.0)); + app.send_synth_pattern(SynthId::B); app.dirty = true; } } else if hit_test_compressor_gauge(col, row, ly.transport) { @@ -88,62 +97,48 @@ fn handle_scroll(app: &mut App, col: u16, row: u16, delta: f32, term_size: Rect) } fn handle_left_down(app: &mut App, col: u16, row: u16, term_size: Rect) { - use crate::app::{FaderDrag, FaderKind}; + let ly = compute_dual_layout(term_size, &app.ui.panel_vis); - let ly = compute_layout(term_size, app.ui.synth_collapsed, app.ui.show_help, app.ui.show_waveform); + // ── Panel toggle clicks ([X] on expanded, anywhere on collapsed) ── + if check_panel_toggle(col, row, &ly, &mut app.ui.panel_vis) { + return; + } // ── Click-to-focus on sections ────────────────────────────────── if hit_test_area(col, row, ly.transport) { app.ui.focus = FocusSection::Transport; - } else if hit_test_area(col, row, ly.synth_grid) { - app.ui.focus = FocusSection::SynthGrid; - } else if hit_test_area(col, row, ly.synth_knobs) { - app.ui.focus = FocusSection::SynthControls; + } else if hit_test_area(col, row, ly.synth_a_grid) { + app.ui.focus = FocusSection::SynthAGrid; + } else if hit_test_area(col, row, ly.synth_a_knobs) { + app.ui.focus = FocusSection::SynthAControls; + } else if hit_test_area(col, row, ly.synth_b_grid) { + app.ui.focus = FocusSection::SynthBGrid; + } else if hit_test_area(col, row, ly.synth_b_knobs) { + app.ui.focus = FocusSection::SynthBControls; } else if hit_test_area(col, row, ly.drum_grid) { app.ui.focus = FocusSection::DrumGrid; - } else if hit_test_area(col, row, ly.knobs) { + } else if hit_test_area(col, row, ly.drum_knobs) { app.ui.focus = FocusSection::Knobs; } - // Check volume faders first - if hit_test_fader(col, row, ly.drum_fader) { - let value = fader_value_from_click(row, ly.drum_fader); - app.effect_params.drum_volume = value; - app.send_effect_params(); - app.dirty = true; - app.ui.mouse.fader_drag = Some(FaderDrag { - kind: FaderKind::Drum, - start_y: row, - start_value: value, - }); - return; - } - if hit_test_fader(col, row, ly.synth_fader) { - let value = fader_value_from_click(row, ly.synth_fader); - app.synth_pattern.params.volume = value; - app.send_synth_pattern(); - app.dirty = true; - app.ui.mouse.fader_drag = Some(FaderDrag { - kind: FaderKind::Synth, - start_y: row, - start_value: value, - }); - return; - } - - // Check each zone - if let Some(step) = hit_test_synth_step(col, row, ly.synth_grid) { - handle_synth_step_click_with_col(app, step, col); - } else if let Some(field) = hit_test_synth_knobs(col, row, ly.synth_knobs) { - handle_synth_knobs_click(app, field, row); - } else if hit_test_area(col, row, ly.synth_knobs) { - app.ui.focus = FocusSection::SynthControls; + // ── Synth A zones ─────────────────────────────────────────────── + if let Some(step) = hit_test_synth_step(col, row, ly.synth_a_grid) { + handle_synth_step_click_with_col(app, SynthId::A, step, col); + } else if let Some(field) = hit_test_synth_knobs(col, row, ly.synth_a_knobs) { + handle_synth_knobs_click(app, SynthId::A, field, row); + // ── Synth B zones ─────────────────────────────────────────────── + } else if let Some(step) = hit_test_synth_step(col, row, ly.synth_b_grid) { + handle_synth_step_click_with_col(app, SynthId::B, step, col); + } else if let Some(field) = hit_test_synth_knobs(col, row, ly.synth_b_knobs) { + handle_synth_knobs_click(app, SynthId::B, field, row); + // ── Drum zones ────────────────────────────────────────────────── } else if let Some((track, step)) = hit_test_grid_step(col, row, ly.drum_grid) { handle_grid_click(app, track, step); } else if let Some((track, is_mute)) = hit_test_mute_solo(col, row, ly.drum_grid) { handle_mute_solo_click(app, track, is_mute); - } else if let Some(field) = hit_test_knobs_panel(col, row, ly.knobs) { + } else if let Some(field) = hit_test_knobs_panel(col, row, ly.drum_knobs) { handle_knobs_click(app, field, row); + // ── Bottom / transport zones ──────────────────────────────────── } else if let Some(track) = hit_test_activity_pad(col, row, ly.activity_bar) { handle_pad_click(app, track); } else if hit_test_compressor_gauge(col, row, ly.transport) { @@ -153,7 +148,6 @@ fn handle_left_down(app: &mut App, col: u16, row: u16, term_size: Rect) { } else if let Some((idx, is_synth)) = hit_test_kit_selector(col, row, ly.transport) { if is_synth { app.switch_synth_kit(idx); } else { app.switch_kit(idx); } } else if hit_test_play_button(col, row, ly.transport) { - // Task 12: Clickable play/stop toggle use crate::sequencer::transport::PlayState; app.transport.state = match app.transport.state { PlayState::Stopped => PlayState::Playing, @@ -162,7 +156,6 @@ fn handle_left_down(app: &mut App, col: u16, row: u16, term_size: Rect) { }; app.send_transport(); } else if hit_test_record_button(col, row, ly.transport) { - // Task 12: Clickable record toggle use crate::sequencer::transport::RecordMode; app.transport.record_mode = match app.transport.record_mode { RecordMode::Off => RecordMode::On, @@ -172,6 +165,86 @@ fn handle_left_down(app: &mut App, col: u16, row: u16, term_size: Rect) { } } +// ── Panel toggle click detection ───────────────────────────────────────────── + +/// Check if a click is on a panel toggle control. +/// Clicking [X] (first 4 chars) of an expanded panel's title bar collapses it. +/// Clicking anywhere on a collapsed bar expands that panel. +/// Returns true if a toggle was performed. +fn check_panel_toggle(col: u16, row: u16, ly: &DualSynthLayout, vis: &mut crate::app::PanelVisibility) -> bool { + // Helper: check collapsed bar (clicking anywhere expands) + fn check_collapsed(col: u16, row: u16, rect: Rect) -> bool { + rect.height > 0 && row >= rect.y && row < rect.y + rect.height + && col >= rect.x && col < rect.x + rect.width + } + // Helper: check expanded title bar [X] region (first 4 chars of first row) + fn check_expanded_toggle(col: u16, row: u16, rect: Rect) -> bool { + rect.height > 0 && row == rect.y && col >= rect.x && col < rect.x + 4 + } + + // Synth A Knobs + if vis.synth_a_knobs && check_expanded_toggle(col, row, ly.synth_a_knobs) { + vis.synth_a_knobs = false; + return true; + } + if !vis.synth_a_knobs && check_collapsed(col, row, ly.synth_a_knobs_collapsed) { + vis.synth_a_knobs = true; + return true; + } + + // Synth A Grid + if vis.synth_a_grid && check_expanded_toggle(col, row, ly.synth_a_grid) { + vis.synth_a_grid = false; + return true; + } + if !vis.synth_a_grid && check_collapsed(col, row, ly.synth_a_grid_collapsed) { + vis.synth_a_grid = true; + return true; + } + + // Synth B Knobs + if vis.synth_b_knobs && check_expanded_toggle(col, row, ly.synth_b_knobs) { + vis.synth_b_knobs = false; + return true; + } + if !vis.synth_b_knobs && check_collapsed(col, row, ly.synth_b_knobs_collapsed) { + vis.synth_b_knobs = true; + return true; + } + + // Synth B Grid + if vis.synth_b_grid && check_expanded_toggle(col, row, ly.synth_b_grid) { + vis.synth_b_grid = false; + return true; + } + if !vis.synth_b_grid && check_collapsed(col, row, ly.synth_b_grid_collapsed) { + vis.synth_b_grid = true; + return true; + } + + // Drum Knobs + if vis.drum_knobs && check_expanded_toggle(col, row, ly.drum_knobs) { + vis.drum_knobs = false; + return true; + } + if !vis.drum_knobs && check_collapsed(col, row, ly.drum_knobs_collapsed) { + vis.drum_knobs = true; + return true; + } + + // Waveform + if vis.waveform && check_expanded_toggle(col, row, ly.waveform) { + vis.waveform = false; + return true; + } + if !vis.waveform && check_collapsed(col, row, ly.waveform_collapsed) { + vis.waveform = true; + return true; + } + + false +} + // ── Synth step hit testing ─────────────────────────────────────────────────── /// Simple area containment check. @@ -206,7 +279,7 @@ fn hit_test_synth_step(col: u16, row: u16, grid_area: Rect) -> Option { } } -fn handle_synth_step_click(app: &mut App, step: usize) { +fn handle_synth_step_click(app: &mut App, synth_id: SynthId, step: usize) { let now = Instant::now(); let is_double = if let Some((prev_time, _prev_track, prev_step)) = app.ui.mouse.last_click { @@ -216,42 +289,49 @@ fn handle_synth_step_click(app: &mut App, step: usize) { false }; + // Route to the correct synth pattern and UI state + let (pattern, ui_state, focus) = match synth_id { + SynthId::A => (&mut app.synth_a_pattern, &mut app.ui.synth_a, FocusSection::SynthAGrid), + SynthId::B => (&mut app.synth_b_pattern, &mut app.ui.synth_b, FocusSection::SynthBGrid), + }; + if is_double { // Double-click: toggle step use crate::sequencer::synth_pattern::SynthStep; - if app.synth_pattern.steps[step].is_active() { - app.synth_pattern.steps[step] = SynthStep { note: 0, velocity: 0, length: 1 }; + if pattern.steps[step].is_active() { + pattern.steps[step] = SynthStep { note: 0, velocity: 0, length: 1 }; } else { - // Insert note at current octave - let note = 60 + (app.ui.synth_octave as u8).wrapping_sub(4) * 12; // C at current octave - app.synth_pattern.steps[step] = SynthStep { note, velocity: 100, length: 1 }; + let note = 60 + (ui_state.octave as u8).wrapping_sub(4) * 12; + pattern.steps[step] = SynthStep { note, velocity: 100, length: 1 }; + } + match synth_id { + SynthId::A => app.send_synth_pattern(SynthId::A), + SynthId::B => app.send_synth_pattern(SynthId::B), } - app.send_synth_pattern(); app.dirty = true; app.ui.mouse.last_click = None; app.ui.mouse.synth_note_drag = None; } else { // Single click: move cursor + focus - app.ui.focus = FocusSection::SynthGrid; - app.ui.synth_cursor_step = step; - app.ui.mouse.last_click = Some((now, 0, step)); // track=0 placeholder for synth + app.ui.focus = focus; + ui_state.cursor_step = step; + app.ui.mouse.last_click = Some((now, 0, step)); - if app.synth_pattern.steps[step].is_active() { - // Active step: start a note-length drag + if pattern.steps[step].is_active() { app.ui.mouse.synth_note_drag = Some(SynthNoteDrag { + synth_id, step, - original_length: app.synth_pattern.steps[step].length, - start_col: 0, // will be set from the event col in handle_left_down + original_length: pattern.steps[step].length, + start_col: 0, }); } else { - // Empty step: create a note use crate::sequencer::synth_pattern::SynthStep; - let note = 60 + (app.ui.synth_octave as u8).wrapping_sub(4) * 12; - app.synth_pattern.steps[step] = SynthStep { note, velocity: 100, length: 1 }; - app.send_synth_pattern(); + let note = 60 + (ui_state.octave as u8).wrapping_sub(4) * 12; + pattern.steps[step] = SynthStep { note, velocity: 100, length: 1 }; + app.send_synth_pattern(synth_id); app.dirty = true; - // Start drag so user can immediately extend the new note app.ui.mouse.synth_note_drag = Some(SynthNoteDrag { + synth_id, step, original_length: 1, start_col: 0, @@ -261,9 +341,8 @@ fn handle_synth_step_click(app: &mut App, step: usize) { } /// Variant that also records the click column for drag calculation. -fn handle_synth_step_click_with_col(app: &mut App, step: usize, col: u16) { - handle_synth_step_click(app, step); - // Update the start_col if a drag was just started +fn handle_synth_step_click_with_col(app: &mut App, synth_id: SynthId, step: usize, col: u16) { + handle_synth_step_click(app, synth_id, step); if let Some(ref mut drag) = app.ui.mouse.synth_note_drag { drag.start_col = col; } @@ -483,25 +562,34 @@ fn hit_adsr_field(col: u16, area: Rect, fields: &[SynthControlField]) -> Option< Some(fields[idx]) } -fn handle_synth_knobs_click(app: &mut App, field: SynthControlField, start_y: u16) { - app.ui.focus = FocusSection::SynthControls; - app.ui.synth_ctrl_field = field; +fn handle_synth_knobs_click(app: &mut App, synth_id: SynthId, field: SynthControlField, start_y: u16) { + let (pattern, ui_state, focus) = match synth_id { + SynthId::A => (&mut app.synth_a_pattern, &mut app.ui.synth_a, FocusSection::SynthAControls), + SynthId::B => (&mut app.synth_b_pattern, &mut app.ui.synth_b, FocusSection::SynthBControls), + }; + + app.ui.focus = focus; + ui_state.ctrl_field = field; // 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 cur = field.get(&app.synth_pattern.params); + 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); - field.set(&mut app.synth_pattern.params, new_int as f32 / max_val as f32); - app.send_synth_pattern(); + field.set(&mut pattern.params, new_int as f32 / max_val as f32); + match synth_id { + SynthId::A => app.send_synth_pattern(SynthId::A), + SynthId::B => app.send_synth_pattern(SynthId::B), + } app.dirty = true; return; } // Start drag for continuous params - let start_value = field.get(&app.synth_pattern.params); + let start_value = field.get(&pattern.params); app.ui.mouse.synth_drag = Some(SynthDrag { + synth_id, field, start_y, start_value, @@ -710,18 +798,22 @@ fn handle_drag(app: &mut App, col: u16, row: u16, _term_size: Rect) { // Check if dragging a synth note (horizontal resize) if let Some(ref drag) = app.ui.mouse.synth_note_drag { let drag = drag.clone(); - // Compute the target step from the column delta - // Each step is 2 chars wide in the grid let col_delta = col as i32 - drag.start_col as i32; - let step_delta = col_delta / 2; // 2 chars per step + let step_delta = col_delta / 2; let new_length = (drag.original_length as i32 + step_delta).clamp(1, 32) as u8; - // Clamp to loop boundary - let loop_len = app.transport.loop_config.synth_length as usize; + let loop_len = match drag.synth_id { + SynthId::A => app.transport.loop_config.synth_a_length as usize, + SynthId::B => app.transport.loop_config.synth_b_length as usize, + }; let max_length = (loop_len - drag.step).min(32) as u8; let clamped = new_length.min(max_length).max(1); - if app.synth_pattern.steps[drag.step].length != clamped { - app.synth_pattern.steps[drag.step].length = clamped; - app.send_synth_pattern(); + let pattern = match drag.synth_id { + SynthId::A => &mut app.synth_a_pattern, + SynthId::B => &mut app.synth_b_pattern, + }; + if pattern.steps[drag.step].length != clamped { + pattern.steps[drag.step].length = clamped; + app.send_synth_pattern(drag.synth_id); app.dirty = true; } return; @@ -744,8 +836,15 @@ fn handle_drag(app: &mut App, col: u16, row: u16, _term_size: Rect) { let d = d.clone(); let delta_y = d.start_y as f32 - row as f32; let new_value = (d.start_value + delta_y * DRAG_SENSITIVITY).clamp(0.0, 1.0); - d.field.set(&mut app.synth_pattern.params, new_value); - app.send_synth_pattern(); + let pattern = match d.synth_id { + SynthId::A => &mut app.synth_a_pattern, + SynthId::B => &mut app.synth_b_pattern, + }; + d.field.set(&mut pattern.params, new_value); + match d.synth_id { + SynthId::A => app.send_synth_pattern(SynthId::A), + SynthId::B => app.send_synth_pattern(SynthId::B), + } app.dirty = true; return; } @@ -886,6 +985,8 @@ fn hit_test_pattern_selector(col: u16, row: u16, transport_area: Rect) -> Option // ── Volume fader hit testing ───────────────────────────────────────────────── /// Check if click is within a fader area. +/// (Currently unused — faders not in DualSynthLayout yet, kept for future re-use.) +#[allow(dead_code)] fn hit_test_fader(col: u16, row: u16, fader_area: Rect) -> bool { col >= fader_area.x && col < fader_area.x + fader_area.width @@ -894,6 +995,8 @@ fn hit_test_fader(col: u16, row: u16, fader_area: Rect) -> bool { } /// Convert a click row to a volume value (0.0 at bottom, 1.0 at top). +/// (Currently unused — faders not in DualSynthLayout yet, kept for future re-use.) +#[allow(dead_code)] fn fader_value_from_click(row: u16, fader_area: Rect) -> f32 { // Inner area (inside border) let inner_top = fader_area.y + 1; @@ -923,8 +1026,8 @@ fn handle_fader_drag(app: &mut App, row: u16) { app.send_effect_params(); } FaderKind::Synth => { - app.synth_pattern.params.volume = new_value; - app.send_synth_pattern(); + app.synth_a_pattern.params.volume = new_value; + app.send_synth_pattern(SynthId::A); } } app.dirty = true; diff --git a/src/presets/mod.rs b/src/presets/mod.rs index d839a57..6cdf205 100644 --- a/src/presets/mod.rs +++ b/src/presets/mod.rs @@ -46,6 +46,7 @@ pub enum PatternMergeMode { #[derive(Clone, Debug, PartialEq)] pub struct PresetBrowserState { pub target: PresetTarget, + pub target_synth: crate::messages::SynthId, // which synth to apply synth presets to pub categories: Vec<&'static str>, pub category_idx: usize, pub preset_names: Vec<&'static str>, @@ -64,6 +65,7 @@ impl PresetBrowserState { .collect(); Self { target: PresetTarget::DrumSound(track), + target_synth: crate::messages::SynthId::A, // default, not used for drum presets categories, category_idx: 0, preset_names: names, @@ -80,6 +82,7 @@ impl PresetBrowserState { .collect(); Self { target: PresetTarget::SynthSound, + target_synth: crate::messages::SynthId::A, // default, will be set when browser opens categories, category_idx: 0, preset_names: names, @@ -150,6 +153,7 @@ impl PresetBrowserState { .collect(); Self { target: PresetTarget::Pattern, + target_synth: crate::messages::SynthId::A, // default, not used for drum patterns categories, category_idx: 0, preset_names: names, @@ -175,6 +179,7 @@ impl PresetBrowserState { .collect(); Self { target: PresetTarget::SynthPattern, + target_synth: crate::messages::SynthId::A, // default, will be set when browser opens categories, category_idx: 0, preset_names: names, diff --git a/src/sequencer/project.rs b/src/sequencer/project.rs index a9ead3f..001ee1b 100644 --- a/src/sequencer/project.rs +++ b/src/sequencer/project.rs @@ -398,6 +398,14 @@ pub struct ProjectFile { pub synth_patterns: Vec, #[serde(default)] pub active_synth_pattern: usize, + #[serde(default)] + pub synth_b_kits: Vec, + #[serde(default)] + pub active_synth_b_kit: usize, + #[serde(default)] + pub synth_b_patterns: Vec, + #[serde(default)] + pub active_synth_b_pattern: usize, } fn default_bpm() -> f64 { 120.0 } @@ -452,6 +460,20 @@ impl Default for ProjectFile { ..Default::default() }); } + let mut synth_b_patterns = Vec::with_capacity(NUM_PATTERNS); + for i in 0..NUM_PATTERNS { + synth_b_patterns.push(SynthPatternData { + name: format!("Synth B {}", i + 1), + ..Default::default() + }); + } + let mut synth_b_kits = Vec::with_capacity(NUM_KITS); + for i in 0..NUM_KITS { + synth_b_kits.push(SynthKitData { + name: format!("Synth B Kit {}", i + 1), + ..Default::default() + }); + } Self { textstep: FileHeader::default(), metadata: ProjectMetadata { @@ -471,6 +493,10 @@ impl Default for ProjectFile { active_synth_kit: 0, synth_patterns, active_synth_pattern: 0, + synth_b_kits, + active_synth_b_kit: 0, + synth_b_patterns, + active_synth_b_pattern: 0, } } } @@ -657,6 +683,20 @@ pub fn demo_project() -> ProjectFile { ..Default::default() }); } + let mut synth_b_patterns = Vec::with_capacity(NUM_PATTERNS); + for i in 0..NUM_PATTERNS { + synth_b_patterns.push(SynthPatternData { + name: format!("Synth B {}", i + 1), + ..Default::default() + }); + } + let mut synth_b_kits = Vec::with_capacity(NUM_KITS); + for i in 0..NUM_KITS { + synth_b_kits.push(SynthKitData { + name: format!("Synth B Kit {}", i + 1), + ..Default::default() + }); + } ProjectFile { textstep: FileHeader::default(), @@ -677,6 +717,10 @@ pub fn demo_project() -> ProjectFile { active_synth_kit: 0, synth_patterns, active_synth_pattern: 0, + synth_b_kits, + active_synth_b_kit: 0, + synth_b_patterns, + active_synth_b_pattern: 0, } } @@ -792,6 +836,34 @@ impl ProjectFile { } } + /// Save synth B pattern steps to project. + pub fn save_synth_b_pattern(&mut self, index: usize, pattern: &SynthPattern) { + if index < self.synth_b_patterns.len() { + self.synth_b_patterns[index] = SynthPatternData::from_synth_pattern(pattern); + } + } + + /// Load synth B pattern steps from project. + pub fn load_synth_b_pattern(&self, index: usize, pattern: &mut SynthPattern) { + if let Some(pat_data) = self.synth_b_patterns.get(index) { + pat_data.apply_to(pattern); + } + } + + /// Save synth B kit params to project. + pub fn save_synth_b_kit(&mut self, index: usize, params: &SynthParams) { + if index < self.synth_b_kits.len() { + self.synth_b_kits[index].params = *params; + } + } + + /// Load synth B kit params into pattern. + pub fn load_synth_b_kit(&self, index: usize, pattern: &mut SynthPattern) { + if let Some(kit_data) = self.synth_b_kits.get(index) { + kit_data.apply_to(pattern); + } + } + /// Ensure we always have NUM_PATTERNS patterns and NUM_KITS kits. pub fn normalize(&mut self) { // Migrate old single-kit format: if kits is empty, seed from legacy kit field @@ -841,6 +913,28 @@ impl ProjectFile { if self.active_synth_pattern >= self.synth_patterns.len() { self.active_synth_pattern = 0; } + + while self.synth_b_kits.len() < NUM_KITS { + let idx = self.synth_b_kits.len(); + self.synth_b_kits.push(SynthKitData { + name: format!("Synth B Kit {}", idx + 1), + ..Default::default() + }); + } + if self.active_synth_b_kit >= self.synth_b_kits.len() { + self.active_synth_b_kit = 0; + } + + while self.synth_b_patterns.len() < NUM_PATTERNS { + let idx = self.synth_b_patterns.len(); + self.synth_b_patterns.push(SynthPatternData { + name: format!("Synth B {}", idx + 1), + ..Default::default() + }); + } + if self.active_synth_b_pattern >= self.synth_b_patterns.len() { + self.active_synth_b_pattern = 0; + } } } @@ -1037,6 +1131,89 @@ mod tests { // Remaining kits are defaults assert_eq!(proj.kits[1].name, "Kit 2"); } + + #[test] + fn test_project_roundtrip_dual_synth() { + // Create a project with synth B data + let mut project = ProjectFile::default(); + + // Set synth B pattern data + project.synth_b_patterns[0].name = "Test Synth B Pattern".to_string(); + project.synth_b_patterns[1].name = "Custom Pattern".to_string(); + project.synth_b_patterns[1].steps[5] = SynthStepData { + active: true, + note: 60, + velocity: 0.8, + gate: 0.9, + length: 4, + }; + + // Set synth B kit data + project.synth_b_kits[0].name = "Test Synth B Kit".to_string(); + project.synth_b_kits[0].params.osc1_level = 0.75; + + project.active_synth_b_kit = 2; + project.active_synth_b_pattern = 3; + + // Serialize + let json = serde_json::to_string(&project).unwrap(); + + // Deserialize + let mut loaded: ProjectFile = serde_json::from_str(&json).unwrap(); + loaded.normalize(); + + // Verify synth B data survived + assert_eq!(loaded.synth_b_patterns[0].name, "Test Synth B Pattern"); + assert_eq!(loaded.synth_b_patterns[1].name, "Custom Pattern"); + assert_eq!(loaded.synth_b_patterns[1].steps[5].active, true); + assert_eq!(loaded.synth_b_patterns[1].steps[5].note, 60); + assert_eq!(loaded.synth_b_patterns[1].steps[5].velocity, 0.8); + assert_eq!(loaded.synth_b_kits[0].name, "Test Synth B Kit"); + assert_eq!(loaded.synth_b_kits[0].params.osc1_level, 0.75); + assert_eq!(loaded.active_synth_b_kit, 2); + assert_eq!(loaded.active_synth_b_pattern, 3); + + // Verify arrays are properly sized + assert_eq!(loaded.synth_b_patterns.len(), NUM_PATTERNS); + assert_eq!(loaded.synth_b_kits.len(), NUM_KITS); + } + + #[test] + fn test_old_project_loads_with_synth_b_defaults() { + // Simulate an old project file without synth_b fields + let json = r#"{ + "textstep": {"format_version": 1, "app_version": "0.1.0"}, + "metadata": {"name": "Old Project"}, + "kits": [{"name": "Kit 1", "tracks": []}], + "active_kit": 0, + "patterns": [{"name": "P1", "steps": []}], + "active_pattern": 0, + "bpm": 120.0, + "loop_length": 32, + "swing": 0.5, + "synth_kits": [{"name": "Synth Kit 1", "params": {}}], + "active_synth_kit": 0, + "synth_patterns": [{"name": "Synth 1", "steps": []}], + "active_synth_pattern": 0 + }"#; + + let mut project: ProjectFile = serde_json::from_str(json).unwrap(); + project.normalize(); + + // Verify synth_b fields get defaults + assert_eq!(project.synth_b_patterns.len(), NUM_PATTERNS); + assert_eq!(project.synth_b_kits.len(), NUM_KITS); + assert_eq!(project.active_synth_b_kit, 0); + assert_eq!(project.active_synth_b_pattern, 0); + + // Default names should be present + assert_eq!(project.synth_b_patterns[0].name, "Synth B 1"); + assert_eq!(project.synth_b_kits[0].name, "Synth B Kit 1"); + + // Old data should be intact + assert_eq!(project.metadata.name, "Old Project"); + assert_eq!(project.synth_patterns[0].name, "Synth 1"); + } } #[cfg(test)] diff --git a/src/sequencer/transport.rs b/src/sequencer/transport.rs index 8e37174..1210148 100644 --- a/src/sequencer/transport.rs +++ b/src/sequencer/transport.rs @@ -1,5 +1,7 @@ //! Transport state: play/pause/stop, BPM, loop configuration, swing amount. +use serde::{Deserialize, Serialize}; + /// Sequencer playback state. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PlayState { @@ -15,19 +17,25 @@ pub enum RecordMode { } /// Per-section loop length settings (8/16/24/32 steps for drum and synth independently). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] pub struct LoopConfig { pub enabled: bool, pub drum_length: u8, // 8, 16, 24, or 32 - pub synth_length: u8, // 8, 16, 24, or 32 + #[serde(alias = "synth_length")] + pub synth_a_length: u8, // 8, 16, 24, or 32 (was: synth_length) + #[serde(default = "default_synth_b_length")] + pub synth_b_length: u8, // 8, 16, 24, or 32 } +fn default_synth_b_length() -> u8 { 16 } + impl Default for LoopConfig { fn default() -> Self { Self { enabled: false, drum_length: 32, - synth_length: 32, + synth_a_length: 32, + synth_b_length: 16, } } } diff --git a/src/ui/layout.rs b/src/ui/layout.rs index 63f7219..ebe545b 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -1,12 +1,21 @@ // src/ui/layout.rs — Single source of truth for all layout dimensions -use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::layout::Rect; -/// Transport bar height (title border + 4 content lines + bottom border) -pub const TRANSPORT_HEIGHT: u16 = 6; +use crate::app::PanelVisibility; + +// ── Dimension constants ────────────────────────────────────────────────────── + +/// Transport bar height (title border + 5 content lines + bottom border). +/// Line 1: play state + BPM + beat LEDs + swing + record +/// Line 2-4: Synth A / Synth B / Drum status lines (pattern/kit/loop) +/// Line 5: master gauges +pub const TRANSPORT_HEIGHT: u16 = 7; /// Height of the drum knobs panel (1 label + 5 bars + 1 value + 2 border). pub const KNOBS_HEIGHT: u16 = 9; +/// Alias used by the new dual-synth layout. +pub const DRUM_KNOBS_HEIGHT: u16 = KNOBS_HEIGHT; /// Height of the synth knobs panel (OSC 8 + ENV/FILT 8 + LFO 3 + AMP 7 + 2 border = 30). pub const SYNTH_KNOBS_HEIGHT: u16 = 30; @@ -14,137 +23,299 @@ pub const SYNTH_KNOBS_HEIGHT: u16 = 30; /// Height of the synth step row (2 border + header + spacer + step row + spacer = 6). pub const SYNTH_GRID_HEIGHT: u16 = 6; -/// Combined synth section height (knobs + steps). -pub const SYNTH_SECTION_HEIGHT: u16 = SYNTH_KNOBS_HEIGHT + SYNTH_GRID_HEIGHT; +/// Minimum height for the drum grid (8 tracks + borders + header). +pub const DRUM_GRID_MIN_HEIGHT: u16 = 11; -/// Synth section when collapsed (border + step row + border) -pub const SYNTH_COLLAPSED_HEIGHT: u16 = 3; - -/// Width of the volume fader column. -pub const FADER_WIDTH: u16 = 3; +/// Height of a collapsed panel (1 top-border + 1 content line showing label). +pub const COLLAPSED_PANEL_HEIGHT: u16 = 2; /// Height of the waveform/oscilloscope panel (including borders). pub const WAVEFORM_HEIGHT: u16 = 11; -/// Activity bar (bottom status line) +/// Activity bar (bottom status line). pub const ACTIVITY_BAR_HEIGHT: u16 = 1; -/// Separator line -pub const SEPARATOR_HEIGHT: u16 = 1; - -/// Help panel height +/// Help panel height. pub const HELP_HEIGHT: u16 = 22; -/// Pre-computed layout rects, shared between render and mouse hit-testing. -pub struct ComputedLayout { +// ── Dual-synth layout ──────────────────────────────────────────────────────── + +/// Pre-computed layout rects for the dual-synth panel system. +/// +/// Each panel has two rects: the expanded rect (non-empty when visible) and +/// the collapsed rect (non-empty when collapsed). They are mutually exclusive — +/// when one is set the other is `Rect::default()`. +pub struct DualSynthLayout { pub transport: Rect, - pub synth_section: Rect, - pub separator: Rect, - pub drum_area: Rect, - pub knobs: Rect, - pub extra: Option, // Help or Waveform panel - pub activity_bar: Rect, - // Synth sub-areas (only valid when expanded) - pub synth_fader: Rect, - pub synth_content: Rect, - pub synth_knobs: Rect, - pub synth_grid: Rect, - // Drum sub-areas - pub drum_fader: Rect, + + // Synth A + pub synth_a_knobs: Rect, + pub synth_a_grid: Rect, + pub synth_a_knobs_collapsed: Rect, + pub synth_a_grid_collapsed: Rect, + + // Synth B + pub synth_b_knobs: Rect, + pub synth_b_grid: Rect, + pub synth_b_knobs_collapsed: Rect, + pub synth_b_grid_collapsed: Rect, + + // Drums pub drum_grid: Rect, + pub drum_knobs: Rect, + pub drum_knobs_collapsed: Rect, + + // Bottom + pub waveform: Rect, + pub waveform_collapsed: Rect, + pub activity_bar: Rect, +} + +/// Describes a single panel slot in the vertical stack. +struct PanelSlot { + expanded_height: u16, + is_visible: bool, + /// If true, this panel receives leftover space when other panels collapse. + growable: bool, +} + +/// Compute layout for the dual-synth panel system. +/// +/// Layout order (top to bottom): +/// Transport | Synth A Knobs | Synth A Grid | Synth B Knobs | Synth B Grid +/// | Drum Grid | Drum Knobs | Waveform | Activity Bar +/// +/// Collapsed panels get `COLLAPSED_PANEL_HEIGHT` (2 lines). +/// Reclaimed vertical space is given to growable panels (drum_grid). +pub fn compute_dual_layout(total: Rect, vis: &PanelVisibility) -> DualSynthLayout { + // Fixed sections: transport at top, activity bar at bottom + let fixed = TRANSPORT_HEIGHT + ACTIVITY_BAR_HEIGHT; + + // Define the 7 collapsible panels in order + let panels = [ + PanelSlot { expanded_height: SYNTH_KNOBS_HEIGHT, is_visible: vis.synth_a_knobs, growable: false }, + PanelSlot { expanded_height: SYNTH_GRID_HEIGHT, is_visible: vis.synth_a_grid, growable: false }, + PanelSlot { expanded_height: SYNTH_KNOBS_HEIGHT, is_visible: vis.synth_b_knobs, growable: false }, + PanelSlot { expanded_height: SYNTH_GRID_HEIGHT, is_visible: vis.synth_b_grid, growable: false }, + PanelSlot { expanded_height: DRUM_GRID_MIN_HEIGHT, is_visible: vis.drum_grid, growable: true }, + PanelSlot { expanded_height: DRUM_KNOBS_HEIGHT, is_visible: vis.drum_knobs, growable: false }, + PanelSlot { expanded_height: WAVEFORM_HEIGHT, is_visible: vis.waveform, growable: false }, + ]; + + // Calculate total requested height (before overflow handling) + let mut used: u16 = fixed; + for p in &panels { + if p.is_visible { + used = used.saturating_add(p.expanded_height); + } else { + used = used.saturating_add(COLLAPSED_PANEL_HEIGHT); + } + } + + // Compute extra space to distribute to growable panels + let available = total.height; + let extra = if available > used { available - used } else { 0 }; + + // Count growable visible panels + let growable_count = panels.iter().filter(|p| p.is_visible && p.growable).count() as u16; + let extra_per_growable = if growable_count > 0 { extra / growable_count } else { 0 }; + let mut extra_remainder = if growable_count > 0 { extra % growable_count } else { 0 }; + + // Assign heights for each panel + let mut heights: [u16; 7] = [0; 7]; + for (i, p) in panels.iter().enumerate() { + if p.is_visible { + heights[i] = p.expanded_height; + if p.growable { + heights[i] += extra_per_growable; + if extra_remainder > 0 { + heights[i] += 1; + extra_remainder -= 1; + } + } + } else { + heights[i] = COLLAPSED_PANEL_HEIGHT; + } + } + + // Build rects by walking y offsets + let x = total.x; + let w = total.width; + let mut y = total.y; + + // Transport + let transport = Rect::new(x, y, w, TRANSPORT_HEIGHT); + y += TRANSPORT_HEIGHT; + + // Helper: allocate a panel rect and advance y + let mut panel_rects: [(Rect, Rect); 7] = [(Rect::default(), Rect::default()); 7]; + for (i, p) in panels.iter().enumerate() { + let h = heights[i]; + let rect = Rect::new(x, y, w, h); + if p.is_visible { + panel_rects[i] = (rect, Rect::default()); // expanded, no collapsed + } else { + panel_rects[i] = (Rect::default(), rect); // no expanded, collapsed + } + y += h; + } + + // Activity bar at the bottom + let activity_bar = Rect::new(x, y, w, ACTIVITY_BAR_HEIGHT); + + DualSynthLayout { + transport, + + synth_a_knobs: panel_rects[0].0, + synth_a_knobs_collapsed: panel_rects[0].1, + synth_a_grid: panel_rects[1].0, + synth_a_grid_collapsed: panel_rects[1].1, + + synth_b_knobs: panel_rects[2].0, + synth_b_knobs_collapsed: panel_rects[2].1, + synth_b_grid: panel_rects[3].0, + synth_b_grid_collapsed: panel_rects[3].1, + + drum_grid: panel_rects[4].0, + drum_knobs: panel_rects[5].0, + drum_knobs_collapsed: panel_rects[5].1, + + waveform: panel_rects[6].0, + waveform_collapsed: panel_rects[6].1, + activity_bar, + } } -/// Compute layout for all sections. Both ui/mod.rs and mouse.rs consume this. -pub fn compute_layout( - size: Rect, - synth_collapsed: bool, - show_help: bool, - show_waveform: bool, -) -> ComputedLayout { - let synth_height = if synth_collapsed { SYNTH_COLLAPSED_HEIGHT } else { SYNTH_SECTION_HEIGHT }; - - let chunks = if show_help { - Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(TRANSPORT_HEIGHT), - Constraint::Length(synth_height), - Constraint::Length(SEPARATOR_HEIGHT), - Constraint::Min(11), - Constraint::Length(KNOBS_HEIGHT), - Constraint::Length(HELP_HEIGHT), - Constraint::Length(ACTIVITY_BAR_HEIGHT), - ]) - .split(size) - } else if show_waveform { - Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(TRANSPORT_HEIGHT), - Constraint::Length(synth_height), - Constraint::Length(SEPARATOR_HEIGHT), - Constraint::Min(11), - Constraint::Length(KNOBS_HEIGHT), - Constraint::Length(WAVEFORM_HEIGHT), - Constraint::Length(ACTIVITY_BAR_HEIGHT), - ]) - .split(size) - } else { - Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(TRANSPORT_HEIGHT), - Constraint::Length(synth_height), - Constraint::Length(SEPARATOR_HEIGHT), - Constraint::Min(11), - Constraint::Length(KNOBS_HEIGHT), - Constraint::Length(ACTIVITY_BAR_HEIGHT), - ]) - .split(size) - }; - - let extra = if show_help || show_waveform { Some(chunks[5]) } else { None }; - let activity_idx = if show_help || show_waveform { 6 } else { 5 }; - - let synth_section = chunks[1]; - - // Synth sub-splits - let synth_h = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Length(FADER_WIDTH), Constraint::Min(20)]) - .split(synth_section); - let synth_fader = synth_h[0]; - let synth_content = synth_h[1]; - - let synth_v = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(SYNTH_KNOBS_HEIGHT), Constraint::Length(SYNTH_GRID_HEIGHT)]) - .split(synth_content); - let synth_knobs = synth_v[0]; - let synth_grid = synth_v[1]; - - // Drum sub-splits - let drum_area = chunks[3]; - let drum_h = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Length(FADER_WIDTH), Constraint::Min(20)]) - .split(drum_area); - let drum_fader = drum_h[0]; - let drum_grid = drum_h[1]; - - ComputedLayout { - transport: chunks[0], - synth_section, - separator: chunks[2], - drum_area, - knobs: chunks[4], - extra, - activity_bar: chunks[activity_idx], - synth_fader, - synth_content, - synth_knobs, - synth_grid, - drum_fader, - drum_grid, +#[cfg(test)] +mod tests { + use super::*; + use crate::app::PanelVisibility; + + fn term(h: u16) -> Rect { + Rect::new(0, 0, 120, h) + } + + #[test] + fn all_expanded_fills_terminal() { + let vis = PanelVisibility { + synth_a_knobs: true, + synth_a_grid: true, + synth_b_knobs: true, + synth_b_grid: true, + drum_grid: true, + drum_knobs: true, + waveform: true, + }; + // Minimum needed: 7 + 30 + 6 + 30 + 6 + 11 + 9 + 11 + 1 = 111 + let ly = compute_dual_layout(term(111), &vis); + + // Transport and activity bar should be at expected positions + assert_eq!(ly.transport.height, TRANSPORT_HEIGHT); + assert_eq!(ly.activity_bar.height, ACTIVITY_BAR_HEIGHT); + assert_eq!(ly.activity_bar.y + ly.activity_bar.height, 111); + + // All expanded rects should be non-empty + assert!(ly.synth_a_knobs.height > 0); + assert!(ly.synth_a_grid.height > 0); + assert!(ly.synth_b_knobs.height > 0); + assert!(ly.synth_b_grid.height > 0); + assert!(ly.drum_grid.height > 0); + assert!(ly.drum_knobs.height > 0); + assert!(ly.waveform.height > 0); + + // All collapsed rects should be empty + assert_eq!(ly.synth_a_knobs_collapsed, Rect::default()); + assert_eq!(ly.synth_b_grid_collapsed, Rect::default()); + assert_eq!(ly.waveform_collapsed, Rect::default()); + } + + #[test] + fn collapsed_panels_give_space_to_drum_grid() { + let vis_expanded = PanelVisibility { + synth_a_knobs: true, + synth_a_grid: true, + synth_b_knobs: true, + synth_b_grid: true, + drum_grid: true, + drum_knobs: true, + waveform: true, + }; + let vis_collapsed = PanelVisibility { + synth_a_knobs: true, + synth_a_grid: true, + synth_b_knobs: false, // collapsed + synth_b_grid: false, // collapsed + drum_grid: true, + drum_knobs: true, + waveform: true, + }; + let h = 120; + let ly_exp = compute_dual_layout(term(h), &vis_expanded); + let ly_col = compute_dual_layout(term(h), &vis_collapsed); + + // Drum grid should be bigger when synth B is collapsed + assert!(ly_col.drum_grid.height > ly_exp.drum_grid.height); + } + + #[test] + fn collapsed_panel_has_collapsed_rect() { + let vis = PanelVisibility { + synth_a_knobs: true, + synth_a_grid: true, + synth_b_knobs: false, + synth_b_grid: false, + drum_grid: true, + drum_knobs: false, + waveform: false, + }; + let ly = compute_dual_layout(term(80), &vis); + + // Synth B knobs: expanded empty, collapsed non-empty + assert_eq!(ly.synth_b_knobs, Rect::default()); + assert_eq!(ly.synth_b_knobs_collapsed.height, COLLAPSED_PANEL_HEIGHT); + + // Drum knobs: collapsed + assert_eq!(ly.drum_knobs, Rect::default()); + assert_eq!(ly.drum_knobs_collapsed.height, COLLAPSED_PANEL_HEIGHT); + + // Waveform: collapsed + assert_eq!(ly.waveform, Rect::default()); + assert_eq!(ly.waveform_collapsed.height, COLLAPSED_PANEL_HEIGHT); + } + + #[test] + fn panels_are_contiguous_vertically() { + let vis = PanelVisibility::default(); + let ly = compute_dual_layout(term(100), &vis); + + // Transport starts at 0 + assert_eq!(ly.transport.y, 0); + + // Each panel starts where the previous one ends + // synth_a_knobs follows transport + let after_transport = ly.transport.y + ly.transport.height; + let sa_knobs_y = if ly.synth_a_knobs.height > 0 { + ly.synth_a_knobs.y + } else { + ly.synth_a_knobs_collapsed.y + }; + assert_eq!(sa_knobs_y, after_transport); + } + + #[test] + fn default_visibility_layout() { + // Default: synth B collapsed, everything else expanded + let vis = PanelVisibility::default(); + let ly = compute_dual_layout(term(100), &vis); + + assert!(ly.synth_a_knobs.height > 0); + assert!(ly.synth_a_grid.height > 0); + assert_eq!(ly.synth_b_knobs, Rect::default()); + assert_eq!(ly.synth_b_grid, Rect::default()); + assert!(ly.synth_b_knobs_collapsed.height > 0); + assert!(ly.synth_b_grid_collapsed.height > 0); + assert!(ly.drum_grid.height > 0); + assert!(ly.drum_knobs.height > 0); + assert!(ly.waveform.height > 0); } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2c369d9..9b2edc6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -20,9 +20,27 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use layout::*; use crate::app::{App, DrumControlField, FocusSection, ModalState, SplashPhase}; +use crate::messages::SynthId; use crate::presets::PresetTarget; use crate::sequencer::drum_pattern::{NUM_DRUM_TRACKS, TRACK_IDS}; +/// Render a collapsed panel bar with [.] indicator showing it can be expanded. +fn render_collapsed_bar(f: &mut Frame, area: Rect, label: &str, focused: bool) { + if area.height == 0 || area.width == 0 { + return; + } + let style = if focused { + Style::default().fg(theme::CYAN) + } else { + Style::default().fg(theme::DIM_TEXT) + }; + let block = Block::default() + .borders(Borders::TOP) + .title(format!("[.] {}", label)) + .title_style(style); + f.render_widget(block, area); +} + /// Top-level render function. pub fn render(f: &mut Frame, app: &App) { let size = f.area(); @@ -36,40 +54,86 @@ pub fn render(f: &mut Frame, app: &App) { // Matrix reveal: render real UI first, then overlay matrix rain on unrevealed cells let matrix_active = app.ui.splash.phase == SplashPhase::MatrixReveal; - // Compute layout once — shared structure between render and mouse - let ly = compute_layout(size, app.ui.synth_collapsed, app.ui.show_help, app.ui.show_waveform); + // Compute dual-synth layout from panel visibility + let ly = compute_dual_layout(size, &app.ui.panel_vis); + // ── Transport ──────────────────────────────────────────────── transport_bar::render_transport(f, ly.transport, app); - if app.ui.synth_collapsed { - render_synth_collapsed(f, ly.synth_section, app); + + // ── Synth A Knobs ──────────────────────────────────────────── + if app.ui.panel_vis.synth_a_knobs { + synth_knobs::render_synth_knobs(f, ly.synth_a_knobs, app, SynthId::A); } else { - render_volume_fader(f, ly.synth_fader, app.synth_pattern.params.volume, "SY"); - synth_knobs::render_synth_knobs(f, ly.synth_knobs, app); - synth_grid::render_synth_grid(f, ly.synth_grid, app); + let focused = matches!(app.ui.focus, FocusSection::SynthAControls); + render_collapsed_bar(f, ly.synth_a_knobs_collapsed, "SYNTH A KNOBS", focused); } - render_separator(f, ly.separator); - render_volume_fader(f, ly.drum_fader, app.effect_params.drum_volume, "DR"); - drum_grid::render_drum_grid(f, ly.drum_grid, app); - knobs::render_knobs(f, ly.knobs, app); - - if let Some(extra) = ly.extra { - if app.ui.show_help { - help_overlay::render_help(f, extra); - } else if app.ui.show_waveform { - let wave_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(3), // VU meter - Constraint::Min(20), // Oscilloscope - ]) - .split(extra); - waveform::render_vu_meter(f, wave_chunks[0], &app.display_buf); - waveform::render_scope_bars(f, wave_chunks[1], &app.ui.scope_bars, &app.ui.scope_intensity); - } + + // ── Synth A Grid ───────────────────────────────────────────── + if app.ui.panel_vis.synth_a_grid { + synth_grid::render_synth_grid(f, ly.synth_a_grid, app, SynthId::A); + } else { + let focused = matches!(app.ui.focus, FocusSection::SynthAGrid); + render_collapsed_bar(f, ly.synth_a_grid_collapsed, "SYNTH A GRID", focused); + } + + // ── Synth B Knobs ──────────────────────────────────────────── + if app.ui.panel_vis.synth_b_knobs { + synth_knobs::render_synth_knobs(f, ly.synth_b_knobs, app, SynthId::B); + } else { + let focused = matches!(app.ui.focus, FocusSection::SynthBControls); + render_collapsed_bar(f, ly.synth_b_knobs_collapsed, "SYNTH B KNOBS", focused); } + // ── Synth B Grid ───────────────────────────────────────────── + if app.ui.panel_vis.synth_b_grid { + synth_grid::render_synth_grid(f, ly.synth_b_grid, app, SynthId::B); + } else { + let focused = matches!(app.ui.focus, FocusSection::SynthBGrid); + render_collapsed_bar(f, ly.synth_b_grid_collapsed, "SYNTH B GRID", focused); + } + + // ── Drum Grid ──────────────────────────────────────────────── + if app.ui.panel_vis.drum_grid { + drum_grid::render_drum_grid(f, ly.drum_grid, app); + } + + // ── Drum Knobs ─────────────────────────────────────────────── + if app.ui.panel_vis.drum_knobs { + knobs::render_knobs(f, ly.drum_knobs, app); + } else { + let focused = matches!(app.ui.focus, FocusSection::Knobs); + render_collapsed_bar(f, ly.drum_knobs_collapsed, "DRUM KNOBS", focused); + } + + // ── Waveform ───────────────────────────────────────────────── + if app.ui.panel_vis.waveform { + let wave_area = ly.waveform; + let wave_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(3), // VU meter + Constraint::Min(20), // Oscilloscope + ]) + .split(wave_area); + waveform::render_vu_meter(f, wave_chunks[0], &app.display_buf); + waveform::render_scope_bars(f, wave_chunks[1], &app.ui.scope_bars, &app.ui.scope_intensity); + } else { + render_collapsed_bar(f, ly.waveform_collapsed, "WAVEFORM", false); + } + + // ── Activity bar ───────────────────────────────────────────── render_activity_bar(f, ly.activity_bar, app); + // ── Help overlay (rendered on top, like a modal) ───────────── + if app.ui.show_help { + // Render help as a centered overlay + let help_h = HELP_HEIGHT.min(size.height.saturating_sub(2)); + let help_y = size.y + (size.height.saturating_sub(help_h)) / 2; + let help_area = Rect::new(size.x, help_y, size.width, help_h); + f.render_widget(Clear, help_area); + help_overlay::render_help(f, help_area); + } + // Matrix rain overlay (covers unrevealed cells) if matrix_active { splash::render_splash(f, size, &app.ui.splash); @@ -93,87 +157,7 @@ pub fn render(f: &mut Frame, app: &App) { } } -// ── Separator ──────────────────────────────────────────────────────────────── - -fn render_separator(f: &mut Frame, area: Rect) { - let line = "─".repeat(area.width as usize); - f.render_widget( - Paragraph::new(Line::from(Span::styled(line, Style::default().fg(Color::DarkGray)))), - area, - ); -} - -// ── Volume faders ──────────────────────────────────────────────────────────── - -/// Render the synth section in collapsed mode: just a title bar with label. -fn render_synth_collapsed(f: &mut Frame, area: Rect, _app: &App) { - let block = Block::default() - .title(" SYNTH [F2 expand] ") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)); - f.render_widget(block, area); -} - -/// Render a vertical volume fader (Hi-Fi LED style, same as VU meter). -fn render_volume_fader(f: &mut Frame, area: Rect, volume: f32, _label: &str) { - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(40, 40, 40))); - - let inner = block.inner(area); - f.render_widget(block, area); - - if inner.height == 0 || inner.width == 0 { - return; - } - - let total_rows = inner.height as usize; - let filled = (volume * total_rows as f32).round() as usize; - - for row_idx in 0..total_rows { - let bar_level = total_rows - 1 - row_idx; // 0=bottom, max=top - let ratio = bar_level as f32 / total_rows.max(1) as f32; - - // Hi-Fi LED color zones - let (base, dim) = if ratio > 0.78 { - ((255u8, 30, 0), (40u8, 5, 0)) // Red - } else if ratio > 0.56 { - ((255, 140, 0), (35, 20, 0)) // Orange - } else if ratio > 0.33 { - ((220, 220, 0), (30, 30, 0)) // Yellow - } else { - ((0, 220, 0), (0, 30, 0)) // Green - }; - - let is_lit = bar_level < filled; - let color = if is_lit { - Color::Rgb(base.0, base.1, base.2) - } else { - Color::Rgb(dim.0, dim.1, dim.2) - }; - - let ch = if is_lit { "\u{2588}" } else { "\u{2591}" }; // █ or ░ - - // Fill the inner width - let y = inner.y + row_idx as u16; - for col in 0..inner.width { - let span = Span::styled(ch, Style::default().fg(color)); - f.render_widget(Paragraph::new(Line::from(span)), Rect::new(inner.x + col, y, 1, 1)); - } - } - - // Show percentage at bottom of fader - let pct = format!("{:02}", (volume * 99.0).round() as u8); - if area.height >= 3 && area.width >= 3 { - // Overlay percentage on the bottom row of the fader border - let pct_y = area.y + area.height - 1; - let pct_style = Style::default().fg(Color::White).add_modifier(Modifier::BOLD); - f.render_widget( - Paragraph::new(Line::from(Span::styled(pct, pct_style))), - Rect::new(area.x, pct_y, area.width, 1), - ); - } -} +// ── Activity bar ───────────────────────────────────────────────────────────── /// Activity bar: trigger pads + param tweak + status message. fn render_activity_bar(f: &mut Frame, area: Rect, app: &App) { diff --git a/src/ui/synth_grid.rs b/src/ui/synth_grid.rs index c037df2..6712e36 100644 --- a/src/ui/synth_grid.rs +++ b/src/ui/synth_grid.rs @@ -7,6 +7,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; use crate::app::{App, FocusSection}; +use crate::messages::SynthId; use crate::sequencer::synth_pattern::MAX_STEPS; use crate::sequencer::transport::PlayState; use crate::ui::theme; @@ -16,15 +17,34 @@ const NAME_WIDTH: usize = 9; /// Renders the synth step row with note names, velocity shading, /// multi-step continuation bars, and playhead/cursor highlights. -pub fn render_synth_grid(f: &mut Frame, area: Rect, app: &App) { - let focused = app.ui.focus == FocusSection::SynthGrid; +pub fn render_synth_grid(f: &mut Frame, area: Rect, app: &App, synth_id: SynthId) { + let (pattern, ui_state, focus_section, loop_len) = match synth_id { + SynthId::A => ( + &app.synth_a_pattern, + &app.ui.synth_a, + FocusSection::SynthAGrid, + app.transport.loop_config.synth_a_length, + ), + SynthId::B => ( + &app.synth_b_pattern, + &app.ui.synth_b, + FocusSection::SynthBGrid, + app.transport.loop_config.synth_b_length, + ), + }; + + let focused = app.ui.focus == focus_section; let border_style = theme::focus_border_style(focused); - let muted = app.synth_pattern.params.mute; + let muted = pattern.params.mute; + + let title = match synth_id { + SynthId::A => format!(" SYNTH A STEPS [{} steps] ", loop_len), + SynthId::B => format!(" SYNTH B STEPS [{} steps] ", loop_len), + }; - let loop_len = app.transport.loop_config.synth_length; let block = Block::default() - .title(format!(" SYNTH STEPS [{} steps] ", loop_len)) + .title(title) .title_style(Style::default().fg(theme::TITLE_COLOR).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_type(BorderType::Thick) @@ -83,7 +103,7 @@ pub fn render_synth_grid(f: &mut Frame, area: Rect, app: &App) { )); } - let playback_step = app.ui.synth_playback_step; + let playback_step = ui_state.playback_step; let is_playing = app.transport.state == PlayState::Playing; // Multi-step note tracking: `covered_until` holds the last step index covered by @@ -100,8 +120,8 @@ pub fn render_synth_grid(f: &mut Frame, area: Rect, app: &App) { )); } - let step = &app.synth_pattern.steps[s]; - let is_cursor = focused && s == app.ui.synth_cursor_step; + let step = &pattern.steps[s]; + let is_cursor = focused && s == ui_state.cursor_step; let is_playhead = is_playing && s == playback_step; let out_of_loop = s >= loop_len as usize; let is_downbeat = s % 4 == 0; diff --git a/src/ui/synth_knobs.rs b/src/ui/synth_knobs.rs index b3834fe..97b2f65 100644 --- a/src/ui/synth_knobs.rs +++ b/src/ui/synth_knobs.rs @@ -7,6 +7,7 @@ use ratatui::text::{Line, Span}; 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::ui::theme; @@ -66,12 +67,22 @@ const ADSR_LABELS: &[&str] = &["A", "D", "S", "R"]; /// Renders the synth parameter panel with grouped slider/ADSR sections /// laid out in four row groups: OSC, ENV+FILT, LFO, and AMP. -pub fn render_synth_knobs(f: &mut Frame, area: Rect, app: &App) { - let focused = app.ui.focus == FocusSection::SynthControls; +pub fn render_synth_knobs(f: &mut Frame, area: Rect, app: &App, synth_id: SynthId) { + let (pattern, ui, focus_section) = match synth_id { + SynthId::A => (&app.synth_a_pattern, &app.ui.synth_a, FocusSection::SynthAControls), + SynthId::B => (&app.synth_b_pattern, &app.ui.synth_b, FocusSection::SynthBControls), + }; + + let focused = app.ui.focus == focus_section; let border_style = theme::focus_border_style(focused); + let title = match synth_id { + SynthId::A => format!(" SYNTH A Oct:{} ", ui.octave), + SynthId::B => format!(" SYNTH B Oct:{} ", ui.octave), + }; + let block = Block::default() - .title(format!(" SYNTH Oct:{} ", app.ui.synth_octave)) + .title(title) .title_style(Style::default().fg(theme::TITLE_COLOR).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_type(BorderType::Thick) @@ -84,8 +95,8 @@ pub fn render_synth_knobs(f: &mut Frame, area: Rect, app: &App) { return; } - let params = &app.synth_pattern.params; - let sel = app.ui.synth_ctrl_field; + let params = &pattern.params; + let sel = ui.ctrl_field; // Split inner into 4 row groups: OSC (8), ENV+FILT (8), LFO (3), AMP (remaining) let row_groups = Layout::default() @@ -216,7 +227,7 @@ pub fn render_synth_knobs(f: &mut Frame, area: Rect, app: &App) { row_groups[3].height.saturating_sub(1), ); - render_amp_group(f, amp_body, app, sel, focused); + render_amp_group(f, amp_body, params, app.effect_params.synth_saturator_drive, sel, focused); } } @@ -599,12 +610,11 @@ fn render_lfo_row( fn render_amp_group( f: &mut Frame, area: Rect, - app: &App, + params: &crate::sequencer::synth_pattern::SynthParams, + sat: f32, selected: SynthControlField, focused: bool, ) { - let params = &app.synth_pattern.params; - let sat = app.effect_params.synth_saturator_drive; // We render 4 columns: Vol, Reverb, Delay, Sat let fields_with_sat: &[(&str, f32, Option)] = &[ diff --git a/src/ui/transport_bar.rs b/src/ui/transport_bar.rs index 447ae38..229c0c4 100644 --- a/src/ui/transport_bar.rs +++ b/src/ui/transport_bar.rs @@ -7,13 +7,9 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; use crate::app::{App, FocusSection}; -use crate::sequencer::project::{NUM_KITS, NUM_PATTERNS}; use crate::sequencer::transport::{PlayState, RecordMode}; use crate::ui::theme; -const PATTERN_KEYS: [char; 10] = ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p']; -const KIT_KEYS: [char; 8] = ['1', '2', '3', '4', '5', '6', '7', '8']; - /// Draws the transport bar: play state, BPM, beat LEDs, swing, record toggle, /// pattern/kit selectors, loop indicators, and master level gauges. pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { @@ -103,35 +99,54 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { let top_line = Line::from(top_spans); - // ── Line 2: Synth machine selector + loop indicator ────────── - let synth_focused = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); - let synth_loop_str = if app.transport.loop_config.enabled { - format!("Loop [ON] S:{}", app.transport.loop_config.synth_length) + // ── Line 2: Synth A status line ─────────────────────────────── + let synth_a_focused = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); + let synth_a_loop_str = if app.transport.loop_config.enabled { + format!("Loop[{}]", app.transport.loop_config.synth_a_length) + } else { + "Loop[--]".to_string() + }; + let synth_a_kit_name = app.project.synth_kits.get(app.ui.synth_a.active_kit) + .map(|k| k.name.as_str()).unwrap_or(""); + let synth_a_line = status_line( + "SA", + app.ui.synth_a.active_pattern, + app.ui.synth_a.queued_pattern, + app.ui.synth_a.active_kit, + synth_a_kit_name, + synth_a_focused, + &synth_a_loop_str, + ); + + // ── Line 3: Synth B status line ─────────────────────────────── + let synth_b_focused = matches!(app.ui.focus, FocusSection::SynthBGrid | FocusSection::SynthBControls); + let synth_b_loop_str = if app.transport.loop_config.enabled { + format!("Loop[{}]", app.transport.loop_config.synth_b_length) } else { - "Loop [OFF]".to_string() + "Loop[--]".to_string() }; - let synth_kit_name = app.project.synth_kits.get(app.ui.synth_active_kit) + let synth_b_kit_name = app.project.synth_kits.get(app.ui.synth_b.active_kit) .map(|k| k.name.as_str()).unwrap_or(""); - let synth_line = machine_selector_line( - "Synth", - app.ui.synth_active_pattern, - app.ui.synth_queued_pattern, - app.ui.synth_active_kit, - synth_kit_name, - synth_focused, - &synth_loop_str, + let synth_b_line = status_line( + "SB", + app.ui.synth_b.active_pattern, + app.ui.synth_b.queued_pattern, + app.ui.synth_b.active_kit, + synth_b_kit_name, + synth_b_focused, + &synth_b_loop_str, ); - // ── Line 3: Drum machine selector + loop indicator ─────────── + // ── Line 4: Drum status line ────────────────────────────────── let drum_focused = matches!(app.ui.focus, FocusSection::DrumGrid | FocusSection::Knobs); let drum_loop_str = if app.transport.loop_config.enabled { - format!("Loop [ON] D:{}", app.transport.loop_config.drum_length) + format!("Loop[{}]", app.transport.loop_config.drum_length) } else { - "Loop [OFF]".to_string() + "Loop[--]".to_string() }; let drum_kit_name = app.current_kit_name(); - let drum_line = machine_selector_line( - "Drum ", + let drum_line = status_line( + "DR", app.ui.active_pattern, app.ui.queued_pattern, app.ui.active_kit, @@ -140,7 +155,7 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { &drum_loop_str, ); - // ── Line 4: Master gauges ──────────────────────────────────── + // ── Line 5: Master gauges ──────────────────────────────────── let gauge_label_style = Style::default().fg(theme::DIM_TEXT); let gauge_fill_style = Style::default().fg(theme::AMBER); let gauge_empty_style = Style::default().fg(theme::SURFACE); @@ -160,7 +175,13 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { gauge_spans(sat, 4, gauge_fill_style, gauge_empty_style), ]); - let paragraph = Paragraph::new(vec![top_line, synth_line, drum_line, gauge_line]).block(block); + let paragraph = Paragraph::new(vec![ + top_line, + synth_a_line, + synth_b_line, + drum_line, + gauge_line, + ]).block(block); f.render_widget(paragraph, area); } @@ -180,19 +201,19 @@ fn gauge_spans<'a>(value: f32, width: usize, fill_style: Style, empty_style: Sty } } -/// Build a Line for a machine's pattern + kit selector row with loop indicator. -fn machine_selector_line<'a>( +/// Build a compact status line showing: Label Pat[N] Kit[N] Loop[N] +fn status_line<'a>( label: &str, active_pattern: usize, queued_pattern: Option, active_kit: usize, - kit_name: &str, + _kit_name: &str, is_focused: bool, loop_info: &str, ) -> Line<'a> { let mut spans: Vec> = Vec::new(); - // Machine label — highlighted when focused + // Section label — highlighted when focused let label_style = if is_focused { Style::default() .fg(theme::CYAN) @@ -200,69 +221,40 @@ fn machine_selector_line<'a>( } else { Style::default().fg(theme::DIM_TEXT) }; - spans.push(Span::styled(format!("{} ", label), label_style)); - - // Pattern selector - spans.push(Span::styled("Pattern: ", Style::default().fg(theme::TEXT))); - for i in 0..NUM_PATTERNS { - let is_active = active_pattern == i; - let is_queued = queued_pattern == Some(i); - let key = PATTERN_KEYS[i]; - - if is_active { - spans.push(Span::styled( - format!("[{}]", key), - Style::default() - .fg(theme::BG) - .bg(theme::AMBER) - .add_modifier(Modifier::BOLD), - )); - } else if is_queued { - spans.push(Span::styled( - format!("[{}]", key), - Style::default() - .fg(theme::BG) - .bg(theme::GOLD) - .add_modifier(Modifier::BOLD), - )); - } else { - spans.push(Span::styled(format!(" {} ", key), Style::default().fg(theme::DIM_TEXT))); - } - } + spans.push(Span::styled(format!("{}: ", label), label_style)); - spans.push(Span::styled(" Kit: ", Style::default().fg(theme::TEXT))); - for i in 0..NUM_KITS { - let is_active = active_kit == i; - let key = KIT_KEYS[i]; + // Pattern indicator (compact) + let pattern_display = if let Some(queued) = queued_pattern { + format!("Pat[{}→{}]", active_pattern + 1, queued + 1) + } else { + format!("Pat[{}]", active_pattern + 1) + }; + let pattern_style = if is_focused { + Style::default().fg(theme::AMBER) + } else { + Style::default().fg(theme::TEXT) + }; + spans.push(Span::styled(pattern_display, pattern_style)); - if is_active { - spans.push(Span::styled( - format!("[{}]", key), - Style::default() - .fg(theme::BG) - .bg(theme::AMBER) - .add_modifier(Modifier::BOLD), - )); + // Kit indicator (compact) + spans.push(Span::styled( + format!(" Kit[{}]", active_kit + 1), + if is_focused { + Style::default().fg(theme::AMBER) } else { - spans.push(Span::styled(format!(" {} ", key), Style::default().fg(theme::DIM_TEXT))); - } - } + Style::default().fg(theme::TEXT) + }, + )); - // Kit name after selector - if !kit_name.is_empty() { - spans.push(Span::styled( - format!(" {}", kit_name), - Style::default().fg(theme::AMBER), - )); - } - - // Loop indicator at end of row - let loop_style = if loop_info.contains("[ON]") { + // Loop indicator + let loop_style = if loop_info.contains("--") { + Style::default().fg(theme::DIM_TEXT) + } else if is_focused { Style::default().fg(theme::CYAN) } else { - Style::default().fg(theme::DIM_TEXT) + Style::default().fg(theme::TEXT) }; - spans.push(Span::styled(format!(" {}", loop_info), loop_style)); + spans.push(Span::styled(format!(" {}", loop_info), loop_style)); Line::from(spans) }