Compare commits

...

2 Commits

Author SHA1 Message Date
Skyler Lehmkuhl ac575482f3 Alphabetize nodes in node finder 2026-02-19 06:07:09 -05:00
Skyler Lehmkuhl 75be94d737 Add step sequencer node 2026-02-19 06:06:41 -05:00
7 changed files with 547 additions and 2 deletions

View File

@ -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())),

View File

@ -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")),

View File

@ -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;

View File

@ -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<u8>,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl SequencerNode {
pub fn new(name: impl Into<String>) -> 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<MidiEvent>],
_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 &note in &self.prev_active_notes {
if !new_notes.contains(&note) {
midi_outputs[0].push(MidiEvent::note_off(0.0, 0, note, 0));
}
}
// Note-on for newly active notes
for &note in &new_notes {
if !self.prev_active_notes.contains(&note) {
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<dyn AudioNode> {
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
}
}

View File

@ -96,7 +96,7 @@ where
ui.set_width(scroll_area_width);
ui.set_min_height(1000.);
for (category, kinds) in categories {
let filtered_kinds: Vec<_> = kinds
let mut filtered_kinds: Vec<_> = kinds
.into_iter()
.map(|kind| {
let kind_name =
@ -109,6 +109,7 @@ where
.contains(self.query.to_lowercase().as_str())
})
.collect();
filtered_kinds.sort_by(|a, b| a.1.cmp(&b.1));
if !filtered_kinds.is_empty() {
let default_open = !self.query.is_empty();

View File

@ -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<NodeId, u32>,
/// 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<NodeId, f32>,
}
@ -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,

View File

@ -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());