Group nodes

This commit is contained in:
Skyler Lehmkuhl 2026-02-16 03:33:32 -05:00
parent ffe7799b6a
commit 0bd933fd45
11 changed files with 1261 additions and 137 deletions

View File

@ -1426,6 +1426,34 @@ impl Engine {
} }
} }
Command::GraphSetGroups(track_id, groups) => {
let graph = match self.project.get_track_mut(track_id) {
Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph),
Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph),
_ => None,
};
if let Some(graph) = graph {
graph.set_frontend_groups(groups);
}
}
Command::GraphSetGroupsInTemplate(track_id, voice_allocator_id, groups) => {
use crate::audio::node_graph::nodes::VoiceAllocatorNode;
let graph = match self.project.get_track_mut(track_id) {
Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph),
Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph),
_ => None,
};
if let Some(graph) = graph {
let node_idx = NodeIndex::new(voice_allocator_id as usize);
if let Some(graph_node) = graph.get_node_mut(node_idx) {
if let Some(va_node) = graph_node.as_any_mut().downcast_mut::<VoiceAllocatorNode>() {
va_node.template_graph_mut().set_frontend_groups(groups);
}
}
}
}
Command::GraphSavePreset(track_id, preset_path, preset_name, description, tags) => { Command::GraphSavePreset(track_id, preset_path, preset_name, description, tags) => {
let graph = match self.project.get_track(track_id) { let graph = match self.project.get_track(track_id) {
Some(TrackNode::Midi(track)) => Some(&track.instrument_graph), Some(TrackNode::Midi(track)) => Some(&track.instrument_graph),
@ -3029,6 +3057,16 @@ impl EngineController {
let _ = self.command_tx.push(Command::GraphSetOutputNode(track_id, node_id)); let _ = self.command_tx.push(Command::GraphSetOutputNode(track_id, node_id));
} }
/// Set frontend-only group definitions on a track's graph
pub fn graph_set_groups(&mut self, track_id: TrackId, groups: Vec<crate::audio::node_graph::preset::SerializedGroup>) {
let _ = self.command_tx.push(Command::GraphSetGroups(track_id, groups));
}
/// Set frontend-only group definitions on a VA template graph
pub fn graph_set_groups_in_template(&mut self, track_id: TrackId, voice_allocator_id: u32, groups: Vec<crate::audio::node_graph::preset::SerializedGroup>) {
let _ = self.command_tx.push(Command::GraphSetGroupsInTemplate(track_id, voice_allocator_id, groups));
}
/// Save the current graph as a preset /// Save the current graph as a preset
pub fn graph_save_preset(&mut self, track_id: TrackId, preset_path: String, preset_name: String, description: String, tags: Vec<String>) { pub fn graph_save_preset(&mut self, track_id: TrackId, preset_path: String, preset_name: String, description: String, tags: Vec<String>) {
let _ = self.command_tx.push(Command::GraphSavePreset(track_id, preset_path, preset_name, description, tags)); let _ = self.command_tx.push(Command::GraphSavePreset(track_id, preset_path, preset_name, description, tags));

View File

@ -98,6 +98,9 @@ pub struct AudioGraph {
/// Cached topological sort order (invalidated on graph mutation) /// Cached topological sort order (invalidated on graph mutation)
topo_cache: Option<Vec<NodeIndex>>, topo_cache: Option<Vec<NodeIndex>>,
/// Frontend-only group definitions (stored opaquely for persistence)
frontend_groups: Vec<crate::audio::node_graph::preset::SerializedGroup>,
} }
impl AudioGraph { impl AudioGraph {
@ -117,6 +120,7 @@ impl AudioGraph {
node_positions: std::collections::HashMap::new(), node_positions: std::collections::HashMap::new(),
playback_time: 0.0, playback_time: 0.0,
topo_cache: None, topo_cache: None,
frontend_groups: Vec::new(),
} }
} }
@ -645,6 +649,10 @@ impl AudioGraph {
self.graph.node_weight(idx).map(|n| &*n.node) self.graph.node_weight(idx).map(|n| &*n.node)
} }
pub fn get_node_mut(&mut self, idx: NodeIndex) -> Option<&mut (dyn AudioNode + 'static)> {
self.graph.node_weight_mut(idx).map(|n| &mut *n.node)
}
/// Get oscilloscope data from a specific node /// Get oscilloscope data from a specific node
pub fn get_oscilloscope_data(&self, idx: NodeIndex, sample_count: usize) -> Option<Vec<f32>> { pub fn get_oscilloscope_data(&self, idx: NodeIndex, sample_count: usize) -> Option<Vec<f32>> {
self.get_node(idx).and_then(|node| node.get_oscilloscope_data(sample_count)) self.get_node(idx).and_then(|node| node.get_oscilloscope_data(sample_count))
@ -729,9 +737,17 @@ impl AudioGraph {
} }
} }
// Clone frontend groups
new_graph.frontend_groups = self.frontend_groups.clone();
new_graph new_graph
} }
/// Set frontend-only group definitions (stored opaquely for persistence)
pub fn set_frontend_groups(&mut self, groups: Vec<crate::audio::node_graph::preset::SerializedGroup>) {
self.frontend_groups = groups;
}
/// Serialize the graph to a preset /// Serialize the graph to a preset
pub fn to_preset(&self, name: impl Into<String>) -> crate::audio::node_graph::preset::GraphPreset { pub fn to_preset(&self, name: impl Into<String>) -> crate::audio::node_graph::preset::GraphPreset {
use crate::audio::node_graph::preset::{GraphPreset, SerializedConnection, SerializedNode}; use crate::audio::node_graph::preset::{GraphPreset, SerializedConnection, SerializedNode};
@ -897,6 +913,9 @@ impl AudioGraph {
// Output node // Output node
preset.output_node = self.output_node.map(|idx| idx.index() as u32); preset.output_node = self.output_node.map(|idx| idx.index() as u32);
// Frontend groups (stored opaquely)
preset.groups = self.frontend_groups.clone();
preset preset
} }
@ -1118,6 +1137,9 @@ impl AudioGraph {
} }
} }
// Restore frontend groups (stored opaquely)
graph.frontend_groups = preset.groups.clone();
Ok(graph) Ok(graph)
} }
} }

View File

@ -6,5 +6,5 @@ pub mod preset;
pub use graph::{Connection, GraphNode, AudioGraph}; pub use graph::{Connection, GraphNode, AudioGraph};
pub use node_trait::{AudioNode, cv_input_or_default}; pub use node_trait::{AudioNode, cv_input_or_default};
pub use preset::{GraphPreset, PresetMetadata, SerializedConnection, SerializedNode}; pub use preset::{GraphPreset, PresetMetadata, SerializedConnection, SerializedNode, SerializedGroup, SerializedBoundaryConnection};
pub use types::{ConnectionError, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; pub use types::{ConnectionError, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};

View File

@ -67,6 +67,10 @@ pub struct GraphPreset {
/// Which node index is the audio output (None if not set) /// Which node index is the audio output (None if not set)
pub output_node: Option<u32>, pub output_node: Option<u32>,
/// Frontend-only group definitions (backend stores opaquely, does not interpret)
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<SerializedGroup>,
} }
/// Metadata about the preset /// Metadata about the preset
@ -121,6 +125,32 @@ pub struct SerializedNode {
pub sample_data: Option<SampleData>, pub sample_data: Option<SampleData>,
} }
/// Serialized group definition (frontend-only visual grouping, stored opaquely by backend)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedGroup {
pub id: u32,
pub name: String,
pub member_nodes: Vec<u32>,
pub position: (f32, f32),
pub boundary_inputs: Vec<SerializedBoundaryConnection>,
pub boundary_outputs: Vec<SerializedBoundaryConnection>,
/// Parent group ID for nested groups (None = top-level group)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_group_id: Option<u32>,
}
/// Serialized boundary connection for group definitions
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedBoundaryConnection {
pub external_node: u32,
pub external_port: usize,
pub internal_node: u32,
pub internal_port: usize,
pub port_name: String,
/// Signal type as string ("Audio", "Midi", "CV")
pub data_type: String,
}
/// Serialized connection between nodes /// Serialized connection between nodes
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedConnection { pub struct SerializedConnection {
@ -152,6 +182,7 @@ impl GraphPreset {
connections: Vec::new(), connections: Vec::new(),
midi_targets: Vec::new(), midi_targets: Vec::new(),
output_node: None, output_node: None,
groups: Vec::new(),
} }
} }

View File

@ -163,6 +163,11 @@ pub enum Command {
/// Set which node is the audio output (track_id, node_index) /// Set which node is the audio output (track_id, node_index)
GraphSetOutputNode(TrackId, u32), GraphSetOutputNode(TrackId, u32),
/// Set frontend-only group definitions on a track's graph (track_id, serialized groups)
GraphSetGroups(TrackId, Vec<crate::audio::node_graph::preset::SerializedGroup>),
/// Set frontend-only group definitions on a VA template graph (track_id, voice_allocator_id, serialized groups)
GraphSetGroupsInTemplate(TrackId, u32, Vec<crate::audio::node_graph::preset::SerializedGroup>),
/// Save current graph as a preset (track_id, preset_path, preset_name, description, tags) /// Save current graph as a preset (track_id, preset_path, preset_name, description, tags)
GraphSavePreset(TrackId, String, String, String, Vec<String>), GraphSavePreset(TrackId, String, String, String, Vec<String>),
/// Load a preset into a track's graph (track_id, preset_path) /// Load a preset into a track's graph (track_id, preset_path)

View File

@ -23,6 +23,10 @@ name = "accesskit"
version = "0.21.1" version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99"
dependencies = [
"enumn",
"serde",
]
[[package]] [[package]]
name = "accesskit_atspi_common" name = "accesskit_atspi_common"
@ -145,6 +149,7 @@ dependencies = [
"cfg-if", "cfg-if",
"getrandom 0.3.4", "getrandom 0.3.4",
"once_cell", "once_cell",
"serde",
"version_check", "version_check",
"zerocopy", "zerocopy",
] ]
@ -1798,6 +1803,7 @@ checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"emath", "emath",
"serde",
] ]
[[package]] [[package]]
@ -1851,6 +1857,8 @@ dependencies = [
"log", "log",
"nohash-hasher", "nohash-hasher",
"profiling", "profiling",
"ron",
"serde",
"smallvec", "smallvec",
"unicode-segmentation", "unicode-segmentation",
] ]
@ -1943,9 +1951,9 @@ dependencies = [
[[package]] [[package]]
name = "egui_node_graph2" name = "egui_node_graph2"
version = "0.7.0" version = "0.7.0"
source = "git+https://github.com/PVDoriginal/egui_node_graph2#a25a90822d8f9c956e729f3907aad98f59fa46bc"
dependencies = [ dependencies = [
"egui", "egui",
"serde",
"slotmap", "slotmap",
"smallvec", "smallvec",
"thiserror 1.0.69", "thiserror 1.0.69",
@ -1964,6 +1972,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"serde",
] ]
[[package]] [[package]]
@ -2022,6 +2031,17 @@ dependencies = [
"syn 2.0.110", "syn 2.0.110",
] ]
[[package]]
name = "enumn"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]] [[package]]
name = "epaint" name = "epaint"
version = "0.33.3" version = "0.33.3"
@ -2038,6 +2058,7 @@ dependencies = [
"nohash-hasher", "nohash-hasher",
"parking_lot", "parking_lot",
"profiling", "profiling",
"serde",
] ]
[[package]] [[package]]
@ -3411,6 +3432,7 @@ dependencies = [
name = "lightningbeam-core" name = "lightningbeam-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"arboard",
"base64 0.21.7", "base64 0.21.7",
"bytemuck", "bytemuck",
"chrono", "chrono",
@ -5293,6 +5315,19 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "ron"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db09040cc89e461f1a265139777a2bde7f8d8c67c4936f700c63ce3e2904d468"
dependencies = [
"base64 0.22.1",
"bitflags 2.10.0",
"serde",
"serde_derive",
"unicode-ident",
]
[[package]] [[package]]
name = "roxmltree" name = "roxmltree"
version = "0.20.0" version = "0.20.0"
@ -5629,6 +5664,7 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a"
dependencies = [ dependencies = [
"serde",
"version_check", "version_check",
] ]

View File

@ -80,6 +80,8 @@ pub struct GraphResponse<UserResponse: UserResponseTrait, NodeData: NodeDataTrai
pub cursor_in_editor: bool, pub cursor_in_editor: bool,
/// Is the mouse currently hovering the node finder? /// Is the mouse currently hovering the node finder?
pub cursor_in_finder: bool, pub cursor_in_finder: bool,
/// Screen-space rects of all rendered nodes (for hit-testing)
pub node_rects: NodeRects,
} }
impl<UserResponse: UserResponseTrait, NodeData: NodeDataTrait> Default impl<UserResponse: UserResponseTrait, NodeData: NodeDataTrait> Default
for GraphResponse<UserResponse, NodeData> for GraphResponse<UserResponse, NodeData>
@ -89,6 +91,7 @@ impl<UserResponse: UserResponseTrait, NodeData: NodeDataTrait> Default
node_responses: Default::default(), node_responses: Default::default(),
cursor_in_editor: false, cursor_in_editor: false,
cursor_in_finder: false, cursor_in_finder: false,
node_rects: NodeRects::new(),
} }
} }
} }
@ -507,8 +510,8 @@ where
); );
self.selected_nodes = node_rects self.selected_nodes = node_rects
.into_iter() .iter()
.filter_map(|(node_id, rect)| { .filter_map(|(&node_id, &rect)| {
if selection_rect.intersects(rect) { if selection_rect.intersects(rect) {
Some(node_id) Some(node_id)
} else { } else {
@ -568,6 +571,7 @@ where
node_responses: delayed_responses, node_responses: delayed_responses,
cursor_in_editor, cursor_in_editor,
cursor_in_finder, cursor_in_finder,
node_rects,
} }
} }
} }

View File

@ -215,4 +215,9 @@ impl GraphBackend for AudioGraphBackend {
Ok(()) Ok(())
} }
fn query_template_state(&self, voice_allocator_id: u32) -> Result<String, String> {
let mut controller = self.audio_controller.lock().unwrap();
controller.query_template_state(self.track_id, voice_allocator_id)
}
} }

View File

@ -79,6 +79,9 @@ pub trait GraphBackend: Send {
input_node: BackendNodeId, input_node: BackendNodeId,
input_port: usize, input_port: usize,
) -> Result<(), String>; ) -> Result<(), String>;
/// Get the state of a VoiceAllocator's template graph as JSON
fn query_template_state(&self, voice_allocator_id: u32) -> Result<String, String>;
} }
/// Serializable graph state (for presets and save/load) /// Serializable graph state (for presets and save/load)

View File

@ -863,7 +863,7 @@ impl NodeTemplateIter for AllNodeTemplates {
NodeTemplate::Oscilloscope, NodeTemplate::Oscilloscope,
// Advanced // Advanced
NodeTemplate::VoiceAllocator, NodeTemplate::VoiceAllocator,
NodeTemplate::Group, // Note: Group is not in the node finder — groups are created via Ctrl+G selection.
// Note: TemplateInput/TemplateOutput are excluded from the default finder. // Note: TemplateInput/TemplateOutput are excluded from the default finder.
// They are added dynamically when editing inside a subgraph. // They are added dynamically when editing inside a subgraph.
// Outputs // Outputs