diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 2a05a3c..f735ea5 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1168,6 +1168,7 @@ impl Engine { "BpmDetector" => Box::new(BpmDetectorNode::new("BPM Detector".to_string())), "Beat" => Box::new(BeatNode::new("Beat".to_string())), "Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator".to_string())), + "Sequencer" => Box::new(SequencerNode::new("Sequencer".to_string())), "EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower".to_string())), "Limiter" => Box::new(LimiterNode::new("Limiter".to_string())), "Math" => Box::new(MathNode::new("Math".to_string())), @@ -1257,6 +1258,7 @@ impl Engine { "BpmDetector" => Box::new(BpmDetectorNode::new("BPM Detector".to_string())), "Beat" => Box::new(BeatNode::new("Beat".to_string())), "Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator".to_string())), + "Sequencer" => Box::new(SequencerNode::new("Sequencer".to_string())), "EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower".to_string())), "Limiter" => Box::new(LimiterNode::new("Limiter".to_string())), "Math" => Box::new(MathNode::new("Math".to_string())), diff --git a/daw-backend/src/audio/node_graph/graph.rs b/daw-backend/src/audio/node_graph/graph.rs index 2933db9..edaf375 100644 --- a/daw-backend/src/audio/node_graph/graph.rs +++ b/daw-backend/src/audio/node_graph/graph.rs @@ -991,6 +991,7 @@ impl AudioGraph { "Constant" => Box::new(ConstantNode::new("Constant")), "Beat" => Box::new(BeatNode::new("Beat")), "Arpeggiator" => Box::new(ArpeggiatorNode::new("Arpeggiator")), + "Sequencer" => Box::new(SequencerNode::new("Sequencer")), "EnvelopeFollower" => Box::new(EnvelopeFollowerNode::new("Envelope Follower")), "Limiter" => Box::new(LimiterNode::new("Limiter")), "Math" => Box::new(MathNode::new("Math")), diff --git a/daw-backend/src/audio/node_graph/nodes/mod.rs b/daw-backend/src/audio/node_graph/nodes/mod.rs index 90fce10..2dcd5eb 100644 --- a/daw-backend/src/audio/node_graph/nodes/mod.rs +++ b/daw-backend/src/audio/node_graph/nodes/mod.rs @@ -34,6 +34,7 @@ mod quantizer; mod reverb; mod ring_modulator; mod sample_hold; +mod sequencer; mod simple_sampler; mod slew_limiter; mod splitter; @@ -78,6 +79,7 @@ pub use quantizer::QuantizerNode; pub use reverb::ReverbNode; pub use ring_modulator::RingModulatorNode; pub use sample_hold::SampleHoldNode; +pub use sequencer::SequencerNode; pub use simple_sampler::SimpleSamplerNode; pub use slew_limiter::SlewLimiterNode; pub use splitter::SplitterNode; diff --git a/daw-backend/src/audio/node_graph/nodes/sequencer.rs b/daw-backend/src/audio/node_graph/nodes/sequencer.rs new file mode 100644 index 0000000..4cd928b --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/sequencer.rs @@ -0,0 +1,307 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; +use crate::audio::midi::MidiEvent; + +const PARAM_MODE: u32 = 0; +const PARAM_STEPS: u32 = 1; +const PARAM_SCALE_MODE: u32 = 2; +const PARAM_KEY: u32 = 3; +const PARAM_SCALE_TYPE: u32 = 4; +const PARAM_OCTAVE: u32 = 5; +const PARAM_VELOCITY: u32 = 6; +const PARAM_ROW_BASE: u32 = 7; +const NUM_ROWS: usize = 8; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum SeqMode { + OnePerCycle = 0, + AllPerCycle = 1, +} + +impl SeqMode { + fn from_f32(v: f32) -> Self { + if v.round() as i32 >= 1 { SeqMode::AllPerCycle } else { SeqMode::OnePerCycle } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ScaleMode { + Chromatic = 0, + Diatonic = 1, +} + +impl ScaleMode { + fn from_f32(v: f32) -> Self { + if v.round() as i32 >= 1 { ScaleMode::Diatonic } else { ScaleMode::Chromatic } + } +} + +/// Scale interval patterns (semitones from root) +const SCALES: &[&[u8]] = &[ + &[0, 2, 4, 5, 7, 9, 11], // Major + &[0, 2, 3, 5, 7, 8, 10], // Minor + &[0, 2, 3, 5, 7, 9, 10], // Dorian + &[0, 2, 4, 5, 7, 9, 10], // Mixolydian + &[0, 2, 4, 7, 9], // Pentatonic Major + &[0, 3, 5, 7, 10], // Pentatonic Minor + &[0, 3, 5, 6, 7, 10], // Blues + &[0, 2, 3, 5, 7, 8, 11], // Harmonic Minor +]; + +/// Step Sequencer node — MxN grid of note triggers with CV phase input and MIDI output. +pub struct SequencerNode { + name: String, + /// Grid state: row_patterns[row] is a u16 bitmask (bit N = step N active) + row_patterns: [u16; 16], + num_steps: usize, + /// Scale mapping + scale_mode: ScaleMode, + key: u8, + scale_type: usize, + base_octave: u8, + velocity: u8, + /// Playback state + mode: SeqMode, + current_step: usize, + prev_phase: f32, + /// Notes currently "on" from the previous step + prev_active_notes: Vec, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl SequencerNode { + pub fn new(name: impl Into) -> Self { + let inputs = vec![ + NodePort::new("Phase", SignalType::CV, 0), + ]; + + let outputs = vec![ + NodePort::new("MIDI Out", SignalType::Midi, 0), + ]; + + let mut parameters = vec![ + Parameter::new(PARAM_MODE, "Mode", 0.0, 1.0, 0.0, ParameterUnit::Generic), + Parameter::new(PARAM_STEPS, "Steps", 0.0, 2.0, 2.0, ParameterUnit::Generic), + Parameter::new(PARAM_SCALE_MODE, "Scale Mode", 0.0, 1.0, 0.0, ParameterUnit::Generic), + Parameter::new(PARAM_KEY, "Key", 0.0, 11.0, 0.0, ParameterUnit::Generic), + Parameter::new(PARAM_SCALE_TYPE, "Scale", 0.0, 7.0, 0.0, ParameterUnit::Generic), + Parameter::new(PARAM_OCTAVE, "Octave", 0.0, 8.0, 4.0, ParameterUnit::Generic), + Parameter::new(PARAM_VELOCITY, "Velocity", 1.0, 127.0, 100.0, ParameterUnit::Generic), + ]; + + // Row bitmask parameters + for row in 0..16u32 { + parameters.push(Parameter::new( + PARAM_ROW_BASE + row, + "Row", + 0.0, + 65535.0, + 0.0, + ParameterUnit::Generic, + )); + } + + Self { + name: name.into(), + row_patterns: [0u16; 16], + num_steps: 16, + scale_mode: ScaleMode::Chromatic, + key: 0, + scale_type: 0, + base_octave: 4, + velocity: 100, + mode: SeqMode::OnePerCycle, + current_step: 0, + prev_phase: 0.0, + prev_active_notes: Vec::new(), + inputs, + outputs, + parameters, + } + } + + fn steps_from_param(v: f32) -> usize { + match v.round() as i32 { + 0 => 4, + 1 => 8, + _ => 16, + } + } + + fn row_to_midi_note(&self, row: usize) -> u8 { + let base = self.key as u16 + self.base_octave as u16 * 12; + let note = match self.scale_mode { + ScaleMode::Chromatic => base + row as u16, + ScaleMode::Diatonic => { + let scale = SCALES[self.scale_type.min(SCALES.len() - 1)]; + let octave_offset = row / scale.len(); + let degree = row % scale.len(); + base + octave_offset as u16 * 12 + scale[degree] as u16 + } + }; + (note as u8).min(127) + } +} + +impl AudioNode for SequencerNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_MODE => self.mode = SeqMode::from_f32(value), + PARAM_STEPS => self.num_steps = Self::steps_from_param(value), + PARAM_SCALE_MODE => self.scale_mode = ScaleMode::from_f32(value), + PARAM_KEY => self.key = (value.round() as u8).min(11), + PARAM_SCALE_TYPE => self.scale_type = (value.round() as usize).min(SCALES.len() - 1), + PARAM_OCTAVE => self.base_octave = (value.round() as u8).min(8), + PARAM_VELOCITY => self.velocity = (value.round() as u8).clamp(1, 127), + id if id >= PARAM_ROW_BASE && id < PARAM_ROW_BASE + 16 => { + let row = (id - PARAM_ROW_BASE) as usize; + self.row_patterns[row] = value.round() as u16; + } + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_MODE => self.mode as i32 as f32, + PARAM_STEPS => match self.num_steps { + 4 => 0.0, + 8 => 1.0, + _ => 2.0, + }, + PARAM_SCALE_MODE => self.scale_mode as i32 as f32, + PARAM_KEY => self.key as f32, + PARAM_SCALE_TYPE => self.scale_type as f32, + PARAM_OCTAVE => self.base_octave as f32, + PARAM_VELOCITY => self.velocity as f32, + id if id >= PARAM_ROW_BASE && id < PARAM_ROW_BASE + 16 => { + let row = (id - PARAM_ROW_BASE) as usize; + self.row_patterns[row] as f32 + } + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + _outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if midi_outputs.is_empty() { + return; + } + + let len = if !inputs.is_empty() { inputs[0].len() } else { return }; + + for i in 0..len { + let phase = cv_input_or_default(inputs, 0, i, 0.0).clamp(0.0, 1.0); + + let new_step = match self.mode { + SeqMode::OnePerCycle => { + if self.prev_phase > 0.7 && phase < 0.3 { + (self.current_step + 1) % self.num_steps + } else { + self.current_step + } + } + SeqMode::AllPerCycle => { + ((phase * self.num_steps as f32).floor() as usize) + .min(self.num_steps - 1) + } + }; + + if new_step != self.current_step { + // Compute active notes for the new step + let mut new_notes = Vec::new(); + for row in 0..NUM_ROWS { + if self.row_patterns[row] & (1 << new_step) != 0 { + let note = self.row_to_midi_note(row); + new_notes.push(note); + } + } + + // Note-off for notes no longer active + for ¬e in &self.prev_active_notes { + if !new_notes.contains(¬e) { + midi_outputs[0].push(MidiEvent::note_off(0.0, 0, note, 0)); + } + } + + // Note-on for newly active notes + for ¬e in &new_notes { + if !self.prev_active_notes.contains(¬e) { + midi_outputs[0].push(MidiEvent::note_on(0.0, 0, note, self.velocity)); + } + } + + self.prev_active_notes = new_notes; + self.current_step = new_step; + } + + self.prev_phase = phase; + } + } + + fn reset(&mut self) { + self.current_step = 0; + self.prev_phase = 0.0; + self.prev_active_notes.clear(); + } + + fn node_type(&self) -> &str { + "Sequencer" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + row_patterns: self.row_patterns, + num_steps: self.num_steps, + scale_mode: self.scale_mode, + key: self.key, + scale_type: self.scale_type, + base_octave: self.base_octave, + velocity: self.velocity, + mode: self.mode, + current_step: 0, + prev_phase: 0.0, + prev_active_notes: Vec::new(), + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs index d42298c..3d28ed1 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs @@ -59,6 +59,7 @@ pub enum NodeTemplate { MidiToCv, AudioToCv, Arpeggiator, + Sequencer, Math, SampleHold, SlewLimiter, @@ -118,6 +119,7 @@ impl NodeTemplate { NodeTemplate::MidiToCv => "MidiToCV", NodeTemplate::AudioToCv => "AudioToCV", NodeTemplate::Arpeggiator => "Arpeggiator", + NodeTemplate::Sequencer => "Sequencer", NodeTemplate::Math => "Math", NodeTemplate::SampleHold => "SampleHold", NodeTemplate::SlewLimiter => "SlewLimiter", @@ -201,6 +203,8 @@ pub struct GraphState { pub node_backend_ids: HashMap, /// Pending root note changes from bottom_ui (node_id, backend_node_id, new_root_note) pub pending_root_note_changes: Vec<(NodeId, u32, u8)>, + /// Pending sequencer grid changes from bottom_ui (node_id, param_id, new_bitmask_value) + pub pending_sequencer_changes: Vec<(NodeId, u32, f32)>, /// Time scale per oscilloscope node (in milliseconds) pub oscilloscope_time_scale: HashMap, } @@ -216,6 +220,7 @@ impl Default for GraphState { sampler_search_text: String::new(), node_backend_ids: HashMap::new(), pending_root_note_changes: Vec::new(), + pending_sequencer_changes: Vec::new(), oscilloscope_time_scale: HashMap::new(), } } @@ -359,6 +364,7 @@ impl NodeTemplateTrait for NodeTemplate { NodeTemplate::MidiToCv => "MIDI to CV".into(), NodeTemplate::AudioToCv => "Audio to CV".into(), NodeTemplate::Arpeggiator => "Arpeggiator".into(), + NodeTemplate::Sequencer => "Step Sequencer".into(), NodeTemplate::Math => "Math".into(), NodeTemplate::SampleHold => "Sample & Hold".into(), NodeTemplate::SlewLimiter => "Slew Limiter".into(), @@ -390,7 +396,7 @@ impl NodeTemplateTrait for NodeTemplate { | NodeTemplate::BitCrusher | NodeTemplate::Compressor | NodeTemplate::Limiter | NodeTemplate::Eq | NodeTemplate::Pan | NodeTemplate::RingModulator | NodeTemplate::Vocoder => vec!["Effects"], NodeTemplate::Adsr | NodeTemplate::Lfo | NodeTemplate::Mixer | NodeTemplate::Splitter - | NodeTemplate::Constant | NodeTemplate::MidiToCv | NodeTemplate::AudioToCv | NodeTemplate::Arpeggiator | NodeTemplate::Math + | NodeTemplate::Constant | NodeTemplate::MidiToCv | NodeTemplate::AudioToCv | NodeTemplate::Arpeggiator | NodeTemplate::Sequencer | NodeTemplate::Math | NodeTemplate::SampleHold | NodeTemplate::SlewLimiter | NodeTemplate::Quantizer | NodeTemplate::EnvelopeFollower | NodeTemplate::BpmDetector | NodeTemplate::Mod => vec!["Utilities"], NodeTemplate::Oscilloscope => vec!["Analysis"], @@ -744,6 +750,44 @@ impl NodeTemplateTrait for NodeTemplate { graph.add_output_param(node_id, "V/Oct".into(), DataType::CV); graph.add_output_param(node_id, "Gate".into(), DataType::CV); } + NodeTemplate::Sequencer => { + graph.add_input_param(node_id, "Phase".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOnly, true); + graph.add_input_param(node_id, "Mode".into(), DataType::CV, + ValueType::float_param(0.0, 0.0, 1.0, "", 0, + Some(&["One/Cycle", "All/Cycle"])), + InputParamKind::ConstantOnly, true); + graph.add_input_param(node_id, "Steps".into(), DataType::CV, + ValueType::float_param(2.0, 0.0, 2.0, "", 1, + Some(&["4", "8", "16"])), + InputParamKind::ConstantOnly, true); + graph.add_input_param(node_id, "Scale".into(), DataType::CV, + ValueType::float_param(0.0, 0.0, 1.0, "", 2, + Some(&["Chromatic", "Diatonic"])), + InputParamKind::ConstantOnly, true); + graph.add_input_param(node_id, "Key".into(), DataType::CV, + ValueType::float_param(0.0, 0.0, 11.0, "", 3, + Some(&["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"])), + InputParamKind::ConstantOnly, true); + graph.add_input_param(node_id, "Scale Type".into(), DataType::CV, + ValueType::float_param(0.0, 0.0, 7.0, "", 4, + Some(&["Major","Minor","Dorian","Mixolydian", + "Penta Maj","Penta Min","Blues","Harm Minor"])), + InputParamKind::ConstantOnly, true); + graph.add_input_param(node_id, "Octave".into(), DataType::CV, + ValueType::float_param(4.0, 0.0, 8.0, "", 5, + Some(&["0","1","2","3","4","5","6","7","8"])), + InputParamKind::ConstantOnly, true); + graph.add_input_param(node_id, "Velocity".into(), DataType::CV, + ValueType::float_param(100.0, 1.0, 127.0, "", 6, None), + InputParamKind::ConstantOnly, true); + // Hidden row bitmask parameters (managed by grid UI) + for row in 0..16u32 { + graph.add_input_param(node_id, format!("Row{}", row).into(), DataType::CV, + ValueType::float_param(0.0, 0.0, 65535.0, "", 7 + row, None), + InputParamKind::ConstantOnly, false); + } + graph.add_output_param(node_id, "MIDI Out".into(), DataType::Midi); + } NodeTemplate::Math => { graph.add_input_param(node_id, "A".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOrConstant, true); graph.add_input_param(node_id, "B".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOrConstant, true); @@ -1070,6 +1114,168 @@ impl NodeDataTrait for NodeData { .suffix(" ms") .logarithmic(true)); }); + } else if self.template == NodeTemplate::Sequencer { + // Read grid parameters from graph inputs + let node = &_graph[node_id]; + + let num_steps = { + let v = node.get_input("Steps").ok() + .and_then(|id| if let ValueType::Float { value, .. } = &_graph.get_input(id).value { Some(*value) } else { None }) + .unwrap_or(2.0); + match v.round() as i32 { 0 => 4usize, 1 => 8, _ => 16 } + }; + let scale_mode_val = node.get_input("Scale").ok() + .and_then(|id| if let ValueType::Float { value, .. } = &_graph.get_input(id).value { Some(*value) } else { None }) + .unwrap_or(0.0); + let key_val = node.get_input("Key").ok() + .and_then(|id| if let ValueType::Float { value, .. } = &_graph.get_input(id).value { Some(*value) } else { None }) + .unwrap_or(0.0) as u8; + let scale_type_val = node.get_input("Scale Type").ok() + .and_then(|id| if let ValueType::Float { value, .. } = &_graph.get_input(id).value { Some(*value) } else { None }) + .unwrap_or(0.0) as usize; + let octave_val = node.get_input("Octave").ok() + .and_then(|id| if let ValueType::Float { value, .. } = &_graph.get_input(id).value { Some(*value) } else { None }) + .unwrap_or(4.0) as u8; + let is_diatonic = scale_mode_val.round() as i32 >= 1; + + // Read row bitmasks + let num_rows = 8usize; + let mut row_patterns = [0u16; 16]; + for row in 0..num_rows { + let name = format!("Row{}", row); + if let Ok(input_id) = node.get_input(&name) { + if let ValueType::Float { value, .. } = &_graph.get_input(input_id).value { + row_patterns[row] = value.round() as u16; + } + } + } + + // Scale intervals for diatonic mode + const SCALES: &[&[u8]] = &[ + &[0,2,4,5,7,9,11], &[0,2,3,5,7,8,10], &[0,2,3,5,7,9,10], &[0,2,4,5,7,9,10], + &[0,2,4,7,9], &[0,3,5,7,10], &[0,3,5,6,7,10], &[0,2,3,5,7,8,11], + ]; + const NOTE_NAMES: &[&str] = &["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"]; + + let row_to_note_name = |row: usize| -> String { + let base = key_val as u16 + octave_val as u16 * 12; + let midi_note = if is_diatonic { + let scale = SCALES[scale_type_val.min(SCALES.len() - 1)]; + let oct_off = row / scale.len(); + let degree = row % scale.len(); + base + oct_off as u16 * 12 + scale[degree] as u16 + } else { + base + row as u16 + }; + let midi_note = (midi_note as u8).min(127); + let name = NOTE_NAMES[(midi_note % 12) as usize]; + let oct = midi_note / 12; + format!("{}{}", name, oct) + }; + + // Grid layout + let label_width = 28.0f32; + let cell_size = 14.0f32; + let grid_width = num_steps as f32 * cell_size; + let grid_height = num_rows as f32 * cell_size; + let total_width = label_width + grid_width; + let total_height = grid_height; + + let (rect, response) = ui.allocate_exact_size( + egui::vec2(total_width, total_height), + egui::Sense::click(), + ); + let painter = ui.painter_at(rect); + + let grid_rect = egui::Rect::from_min_size( + egui::pos2(rect.left() + label_width, rect.top()), + egui::vec2(grid_width, grid_height), + ); + + // Background + painter.rect_filled(grid_rect, 0.0, egui::Color32::from_rgb(0x1a, 0x1a, 0x1a)); + + // Draw cells (bottom row = row 0 = lowest pitch) + let active_color = egui::Color32::from_rgb(0x4C, 0xAF, 0x50); + let hover_color = egui::Color32::from_rgb(0x66, 0xBB, 0x6A); + let grid_line_color = egui::Color32::from_rgb(0x33, 0x33, 0x33); + + // Get hover position + let hover_cell = response.hover_pos().and_then(|pos| { + if grid_rect.contains(pos) { + let col = ((pos.x - grid_rect.left()) / cell_size).floor() as usize; + let visual_row = ((pos.y - grid_rect.top()) / cell_size).floor() as usize; + if col < num_steps && visual_row < num_rows { + Some((num_rows - 1 - visual_row, col)) + } else { + None + } + } else { + None + } + }); + + for visual_row in 0..num_rows { + let row = num_rows - 1 - visual_row; // flip: top = highest pitch + for col in 0..num_steps { + let cell_rect = egui::Rect::from_min_size( + egui::pos2( + grid_rect.left() + col as f32 * cell_size, + grid_rect.top() + visual_row as f32 * cell_size, + ), + egui::vec2(cell_size, cell_size), + ); + let active = row_patterns[row] & (1 << col) != 0; + let hovered = hover_cell == Some((row, col)); + + if active { + let color = if hovered { hover_color } else { active_color }; + painter.rect_filled(cell_rect.shrink(0.5), 1.0, color); + } else if hovered { + painter.rect_filled(cell_rect.shrink(0.5), 1.0, egui::Color32::from_rgb(0x2a, 0x2a, 0x2a)); + } + } + } + + // Grid lines + for i in 0..=num_steps { + let x = grid_rect.left() + i as f32 * cell_size; + let color = if i % 4 == 0 { egui::Color32::from_rgb(0x55, 0x55, 0x55) } else { grid_line_color }; + painter.line_segment( + [egui::pos2(x, grid_rect.top()), egui::pos2(x, grid_rect.bottom())], + egui::Stroke::new(1.0, color), + ); + } + for i in 0..=num_rows { + let y = grid_rect.top() + i as f32 * cell_size; + painter.line_segment( + [egui::pos2(grid_rect.left(), y), egui::pos2(grid_rect.right(), y)], + egui::Stroke::new(1.0, grid_line_color), + ); + } + + // Note labels on the left + for visual_row in 0..num_rows { + let row = num_rows - 1 - visual_row; + let label = row_to_note_name(row); + let y = grid_rect.top() + visual_row as f32 * cell_size + cell_size * 0.5; + painter.text( + egui::pos2(rect.left() + label_width - 2.0, y), + egui::Align2::RIGHT_CENTER, + &label, + egui::FontId::monospace(8.0), + egui::Color32::from_rgb(0x99, 0x99, 0x99), + ); + } + + // Handle click to toggle cell + if response.clicked() { + if let Some((row, col)) = hover_cell { + let new_bitmask = row_patterns[row] ^ (1 << col); + let param_id = 7 + row as u32; + user_state.pending_sequencer_changes.push((node_id, param_id, new_bitmask as f32)); + } + } } else { ui.label(""); } @@ -1151,6 +1357,7 @@ impl NodeTemplateIter for AllNodeTemplates { NodeTemplate::MidiToCv, NodeTemplate::AudioToCv, NodeTemplate::Arpeggiator, + NodeTemplate::Sequencer, NodeTemplate::Math, NodeTemplate::SampleHold, NodeTemplate::SlewLimiter, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs index 5e5b790..eeacd2c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -1964,6 +1964,7 @@ impl NodeGraphPane { "Mod" => Some(NodeTemplate::Mod), "Oscilloscope" => Some(NodeTemplate::Oscilloscope), "Arpeggiator" => Some(NodeTemplate::Arpeggiator), + "Sequencer" => Some(NodeTemplate::Sequencer), "Beat" => Some(NodeTemplate::Beat), "VoiceAllocator" => Some(NodeTemplate::VoiceAllocator), "Group" => Some(NodeTemplate::Group), @@ -2400,6 +2401,30 @@ impl crate::panes::PaneRenderer for NodeGraphPane { } } + // Handle pending sequencer grid changes + if !self.user_state.pending_sequencer_changes.is_empty() { + let changes: Vec<_> = self.user_state.pending_sequencer_changes.drain(..).collect(); + if let Some(backend_track_id) = self.track_id.and_then(|tid| shared.layer_to_track_map.get(&tid).copied()) { + if let Some(controller_arc) = &shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + for (node_id, param_id, value) in changes { + // Send to backend + if let Some(backend_id) = self.node_id_map.get(&node_id) { + let BackendNodeId::Audio(node_idx) = backend_id; + controller.graph_set_parameter(backend_track_id, node_idx.index() as u32, param_id, value); + } + // Update frontend graph value + let row_name = format!("Row{}", param_id - 7); + if let Ok(input_id) = self.state.graph[node_id].get_input(&row_name) { + if let ValueType::Float { value: ref mut v, .. } = self.state.graph.inputs[input_id].value { + *v = value; + } + } + } + } + } + } + // Detect right-click on nodes — intercept the library's node finder and show our context menu instead { let secondary_clicked = ui.input(|i| i.pointer.secondary_released());