From 0bd933fd457c7f9d452ac760010e6d5690c65302 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 16 Feb 2026 03:33:32 -0500 Subject: [PATCH 1/4] Group nodes --- daw-backend/src/audio/engine.rs | 38 + daw-backend/src/audio/node_graph/graph.rs | 22 + daw-backend/src/audio/node_graph/mod.rs | 2 +- daw-backend/src/audio/node_graph/preset.rs | 31 + daw-backend/src/command/types.rs | 5 + lightningbeam-ui/Cargo.lock | 38 +- .../egui_node_graph2/src/editor_ui.rs | 8 +- .../src/panes/node_graph/audio_backend.rs | 5 + .../src/panes/node_graph/backend.rs | 3 + .../src/panes/node_graph/graph_data.rs | 2 +- .../src/panes/node_graph/mod.rs | 1244 +++++++++++++++-- 11 files changed, 1261 insertions(+), 137 deletions(-) diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 181b6c3..833fae1 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -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::() { + va_node.template_graph_mut().set_frontend_groups(groups); + } + } + } + } + Command::GraphSavePreset(track_id, preset_path, preset_name, description, tags) => { let graph = match self.project.get_track(track_id) { 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)); } + /// Set frontend-only group definitions on a track's graph + pub fn graph_set_groups(&mut self, track_id: TrackId, groups: Vec) { + 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) { + let _ = self.command_tx.push(Command::GraphSetGroupsInTemplate(track_id, voice_allocator_id, groups)); + } + /// 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) { let _ = self.command_tx.push(Command::GraphSavePreset(track_id, preset_path, preset_name, description, tags)); diff --git a/daw-backend/src/audio/node_graph/graph.rs b/daw-backend/src/audio/node_graph/graph.rs index 7638f4a..ea031e7 100644 --- a/daw-backend/src/audio/node_graph/graph.rs +++ b/daw-backend/src/audio/node_graph/graph.rs @@ -98,6 +98,9 @@ pub struct AudioGraph { /// Cached topological sort order (invalidated on graph mutation) topo_cache: Option>, + + /// Frontend-only group definitions (stored opaquely for persistence) + frontend_groups: Vec, } impl AudioGraph { @@ -117,6 +120,7 @@ impl AudioGraph { node_positions: std::collections::HashMap::new(), playback_time: 0.0, topo_cache: None, + frontend_groups: Vec::new(), } } @@ -645,6 +649,10 @@ impl AudioGraph { 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 pub fn get_oscilloscope_data(&self, idx: NodeIndex, sample_count: usize) -> Option> { 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 } + /// Set frontend-only group definitions (stored opaquely for persistence) + pub fn set_frontend_groups(&mut self, groups: Vec) { + self.frontend_groups = groups; + } + /// Serialize the graph to a preset pub fn to_preset(&self, name: impl Into) -> crate::audio::node_graph::preset::GraphPreset { use crate::audio::node_graph::preset::{GraphPreset, SerializedConnection, SerializedNode}; @@ -897,6 +913,9 @@ impl AudioGraph { // Output node preset.output_node = self.output_node.map(|idx| idx.index() as u32); + // Frontend groups (stored opaquely) + preset.groups = self.frontend_groups.clone(); + preset } @@ -1118,6 +1137,9 @@ impl AudioGraph { } } + // Restore frontend groups (stored opaquely) + graph.frontend_groups = preset.groups.clone(); + Ok(graph) } } diff --git a/daw-backend/src/audio/node_graph/mod.rs b/daw-backend/src/audio/node_graph/mod.rs index 6d81906..08c313c 100644 --- a/daw-backend/src/audio/node_graph/mod.rs +++ b/daw-backend/src/audio/node_graph/mod.rs @@ -6,5 +6,5 @@ pub mod preset; pub use graph::{Connection, GraphNode, AudioGraph}; 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}; diff --git a/daw-backend/src/audio/node_graph/preset.rs b/daw-backend/src/audio/node_graph/preset.rs index 9a67125..fd760be 100644 --- a/daw-backend/src/audio/node_graph/preset.rs +++ b/daw-backend/src/audio/node_graph/preset.rs @@ -67,6 +67,10 @@ pub struct GraphPreset { /// Which node index is the audio output (None if not set) pub output_node: Option, + + /// Frontend-only group definitions (backend stores opaquely, does not interpret) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub groups: Vec, } /// Metadata about the preset @@ -121,6 +125,32 @@ pub struct SerializedNode { pub sample_data: Option, } +/// 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, + pub position: (f32, f32), + pub boundary_inputs: Vec, + pub boundary_outputs: Vec, + /// Parent group ID for nested groups (None = top-level group) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_group_id: Option, +} + +/// 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 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SerializedConnection { @@ -152,6 +182,7 @@ impl GraphPreset { connections: Vec::new(), midi_targets: Vec::new(), output_node: None, + groups: Vec::new(), } } diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 85b692d..21f0a63 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -163,6 +163,11 @@ pub enum Command { /// Set which node is the audio output (track_id, node_index) GraphSetOutputNode(TrackId, u32), + /// Set frontend-only group definitions on a track's graph (track_id, serialized groups) + GraphSetGroups(TrackId, Vec), + /// Set frontend-only group definitions on a VA template graph (track_id, voice_allocator_id, serialized groups) + GraphSetGroupsInTemplate(TrackId, u32, Vec), + /// Save current graph as a preset (track_id, preset_path, preset_name, description, tags) GraphSavePreset(TrackId, String, String, String, Vec), /// Load a preset into a track's graph (track_id, preset_path) diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index c154343..a4eec0b 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -23,6 +23,10 @@ name = "accesskit" version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" +dependencies = [ + "enumn", + "serde", +] [[package]] name = "accesskit_atspi_common" @@ -145,6 +149,7 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -1798,6 +1803,7 @@ checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" dependencies = [ "bytemuck", "emath", + "serde", ] [[package]] @@ -1851,6 +1857,8 @@ dependencies = [ "log", "nohash-hasher", "profiling", + "ron", + "serde", "smallvec", "unicode-segmentation", ] @@ -1943,9 +1951,9 @@ dependencies = [ [[package]] name = "egui_node_graph2" version = "0.7.0" -source = "git+https://github.com/PVDoriginal/egui_node_graph2#a25a90822d8f9c956e729f3907aad98f59fa46bc" dependencies = [ "egui", + "serde", "slotmap", "smallvec", "thiserror 1.0.69", @@ -1964,6 +1972,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" dependencies = [ "bytemuck", + "serde", ] [[package]] @@ -2022,6 +2031,17 @@ dependencies = [ "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]] name = "epaint" version = "0.33.3" @@ -2038,6 +2058,7 @@ dependencies = [ "nohash-hasher", "parking_lot", "profiling", + "serde", ] [[package]] @@ -3411,6 +3432,7 @@ dependencies = [ name = "lightningbeam-core" version = "0.1.0" dependencies = [ + "arboard", "base64 0.21.7", "bytemuck", "chrono", @@ -5293,6 +5315,19 @@ dependencies = [ "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]] name = "roxmltree" version = "0.20.0" @@ -5629,6 +5664,7 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" dependencies = [ + "serde", "version_check", ] diff --git a/lightningbeam-ui/egui_node_graph2/src/editor_ui.rs b/lightningbeam-ui/egui_node_graph2/src/editor_ui.rs index 1693f18..75b7a92 100644 --- a/lightningbeam-ui/egui_node_graph2/src/editor_ui.rs +++ b/lightningbeam-ui/egui_node_graph2/src/editor_ui.rs @@ -80,6 +80,8 @@ pub struct GraphResponse Default for GraphResponse @@ -89,6 +91,7 @@ impl Default node_responses: Default::default(), cursor_in_editor: false, cursor_in_finder: false, + node_rects: NodeRects::new(), } } } @@ -507,8 +510,8 @@ where ); self.selected_nodes = node_rects - .into_iter() - .filter_map(|(node_id, rect)| { + .iter() + .filter_map(|(&node_id, &rect)| { if selection_rect.intersects(rect) { Some(node_id) } else { @@ -568,6 +571,7 @@ where node_responses: delayed_responses, cursor_in_editor, cursor_in_finder, + node_rects, } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/audio_backend.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/audio_backend.rs index 5b35e71..4a9b587 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/audio_backend.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/audio_backend.rs @@ -215,4 +215,9 @@ impl GraphBackend for AudioGraphBackend { Ok(()) } + + fn query_template_state(&self, voice_allocator_id: u32) -> Result { + let mut controller = self.audio_controller.lock().unwrap(); + controller.query_template_state(self.track_id, voice_allocator_id) + } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/backend.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/backend.rs index 75a026b..e0ed183 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/backend.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/backend.rs @@ -79,6 +79,9 @@ pub trait GraphBackend: Send { input_node: BackendNodeId, input_port: usize, ) -> Result<(), String>; + + /// Get the state of a VoiceAllocator's template graph as JSON + fn query_template_state(&self, voice_allocator_id: u32) -> Result; } /// Serializable graph state (for presets and save/load) 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 b14f092..f0e7485 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 @@ -863,7 +863,7 @@ impl NodeTemplateIter for AllNodeTemplates { NodeTemplate::Oscilloscope, // Advanced 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. // They are added dynamically when editing inside a subgraph. // Outputs 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 3bfd5cf..f9e3e66 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -13,14 +13,50 @@ use graph_data::{AllNodeTemplates, SubgraphNodeTemplates, DataType, GraphState, use super::NodePath; use eframe::egui; use egui_node_graph2::*; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use uuid::Uuid; +type GroupId = u32; + +/// A connection that crosses a group boundary +#[derive(Clone, Debug)] +struct BoundaryConnection { + /// Node outside the group (backend ID) + external_node: u32, + /// Port index on the external node + external_port: usize, + /// Node inside the group (backend ID) + internal_node: u32, + /// Port index on the internal node + internal_port: usize, + /// Display name for the group port + port_name: String, + /// Signal type for the port + data_type: DataType, +} + +/// A group of nodes collapsed into a single placeholder +#[derive(Clone, Debug)] +struct GroupDef { + id: GroupId, + name: String, + /// Backend node IDs of nodes belonging to this group + member_nodes: Vec, + /// Position of the group placeholder node + position: (f32, f32), + /// Connections from outside → inside the group + boundary_inputs: Vec, + /// Connections from inside → outside the group + boundary_outputs: Vec, + /// Parent group ID for nested groups (None = top-level group) + parent_group_id: Option, +} + /// What kind of container we've entered for subgraph editing #[derive(Clone, Debug)] enum SubgraphContext { - VoiceAllocator { frontend_id: NodeId, backend_id: BackendNodeId }, - Group { frontend_id: NodeId, backend_id: BackendNodeId, name: String }, + VoiceAllocator { backend_id: BackendNodeId }, + Group { group_id: GroupId, name: String }, } /// One level of subgraph editing — stores the parent state we'll restore on exit @@ -36,6 +72,11 @@ struct SavedGraphState { node_id_map: HashMap, backend_to_frontend_map: HashMap, parameter_values: HashMap, + /// Groups are only saved/restored for VA transitions. For Group transitions, + /// groups persist in self (so sub-groups aren't lost on exit). + groups: Option>, + next_group_id: Option, + group_placeholder_map: HashMap, } /// Node graph pane with egui_node_graph2 integration @@ -82,6 +123,19 @@ pub struct NodeGraphPane { /// Stack of subgraph contexts — empty = editing track-level graph, /// non-empty = editing nested subgraph(s). Supports arbitrary nesting depth. subgraph_stack: Vec, + + /// Group definitions (frontend-only — backend graph stays flat) + groups: Vec, + /// Next group ID to assign + next_group_id: GroupId, + /// Maps frontend NodeId → GroupId for group placeholder nodes + group_placeholder_map: HashMap, + /// Group currently being renamed (shows text edit popup) + renaming_group: Option<(GroupId, String)>, + /// Right-click context menu state: (node_id, screen_pos) + node_context_menu: Option<(NodeId, egui::Pos2)>, + /// Cached node screen rects from last frame (for hit-testing) + last_node_rects: std::collections::HashMap, } impl NodeGraphPane { @@ -102,6 +156,12 @@ impl NodeGraphPane { dragging_node: None, insert_target: None, subgraph_stack: Vec::new(), + groups: Vec::new(), + next_group_id: 1, + group_placeholder_map: HashMap::new(), + renaming_group: None, + node_context_menu: None, + last_node_rects: HashMap::new(), } } @@ -130,6 +190,12 @@ impl NodeGraphPane { dragging_node: None, insert_target: None, subgraph_stack: Vec::new(), + groups: Vec::new(), + next_group_id: 1, + group_placeholder_map: HashMap::new(), + renaming_group: None, + node_context_menu: None, + last_node_rects: HashMap::new(), }; // Load existing graph from backend @@ -312,6 +378,15 @@ impl NodeGraphPane { } } NodeResponse::DeleteNodeFull { node_id, .. } => { + // If this is a group placeholder, ungroup instead of deleting + if let Some(&group_id) = self.group_placeholder_map.get(&node_id) { + self.groups.retain(|g| g.id != group_id); + // Will rebuild view after response handling + self.rebuild_view(); + self.sync_groups_to_backend(shared); + continue; + } + // Node was deleted if let Some(track_id) = self.track_id { if let Some(&backend_id) = self.node_id_map.get(&node_id) { @@ -344,6 +419,16 @@ impl NodeGraphPane { self.user_state.active_node = Some(node); self.dragging_node = Some(node); + // Update group placeholder position (frontend-only, no backend sync) + if let Some(&group_id) = self.group_placeholder_map.get(&node) { + if let Some(pos) = self.state.node_positions.get(node) { + if let Some(group) = self.groups.iter_mut().find(|g| g.id == group_id) { + group.position = (pos.x, pos.y); + } + } + continue; + } + // Sync updated position to backend if let Some(&backend_id) = self.node_id_map.get(&node) { if let Some(pos) = self.state.node_positions.get(node) { @@ -384,7 +469,6 @@ impl NodeGraphPane { if let Some(&backend_id) = self.node_id_map.get(&node_id) { self.enter_subgraph( SubgraphContext::VoiceAllocator { - frontend_id: node_id, backend_id, }, shared, @@ -394,12 +478,11 @@ impl NodeGraphPane { } NodeTemplate::Group => { // Groups can nest arbitrarily deep - if let Some(&backend_id) = self.node_id_map.get(&node_id) { + if let Some(&group_id) = self.group_placeholder_map.get(&node_id) { let name = node.label.clone(); self.enter_subgraph( SubgraphContext::Group { - frontend_id: node_id, - backend_id, + group_id, name, }, shared, @@ -903,13 +986,26 @@ impl NodeGraphPane { context: SubgraphContext, shared: &mut crate::panes::SharedPaneState, ) { - // Save current state + let is_va = matches!(context, SubgraphContext::VoiceAllocator { .. }); + + // Only save/restore groups for VA transitions. + // For Group transitions, groups persist in self so sub-groups aren't lost. + let (saved_groups, saved_next_group_id) = if is_va { + (Some(std::mem::take(&mut self.groups)), Some(std::mem::replace(&mut self.next_group_id, 1))) + } else { + (None, None) + }; + + // Save current editor state let saved = SavedGraphState { state: std::mem::replace(&mut self.state, GraphEditorState::new(1.0)), user_state: std::mem::replace(&mut self.user_state, GraphState::default()), node_id_map: std::mem::take(&mut self.node_id_map), backend_to_frontend_map: std::mem::take(&mut self.backend_to_frontend_map), parameter_values: std::mem::take(&mut self.parameter_values), + groups: saved_groups, + next_group_id: saved_next_group_id, + group_placeholder_map: std::mem::take(&mut self.group_placeholder_map), }; self.subgraph_stack.push(SubgraphFrame { @@ -940,7 +1036,9 @@ impl NodeGraphPane { } } SubgraphContext::Group { .. } => { - // TODO: query_subgraph_state when group backend is implemented + // Groups are frontend-only. Rebuild the view scoped to this group, + // showing member nodes, sub-group placeholders, and boundary indicators. + self.rebuild_view(); } } } @@ -953,6 +1051,14 @@ impl NodeGraphPane { self.node_id_map = frame.saved_state.node_id_map; self.backend_to_frontend_map = frame.saved_state.backend_to_frontend_map; self.parameter_values = frame.saved_state.parameter_values; + // Only restore groups if they were saved (VA transitions save them, Group transitions don't) + if let Some(groups) = frame.saved_state.groups { + self.groups = groups; + } + if let Some(next_id) = frame.saved_state.next_group_id { + self.next_group_id = next_id; + } + self.group_placeholder_map = frame.saved_state.group_placeholder_map; } } @@ -983,152 +1089,153 @@ impl NodeGraphPane { // Create nodes in frontend for node in &graph_state.nodes { - let node_template = match node.node_type.as_str() { - "MidiInput" => NodeTemplate::MidiInput, - "AudioInput" => NodeTemplate::AudioInput, - "AutomationInput" => NodeTemplate::AutomationInput, - "Oscillator" => NodeTemplate::Oscillator, - "WavetableOscillator" => NodeTemplate::WavetableOscillator, - "FMSynth" => NodeTemplate::FmSynth, - "NoiseGenerator" => NodeTemplate::Noise, - "SimpleSampler" => NodeTemplate::SimpleSampler, - "MultiSampler" => NodeTemplate::MultiSampler, - "Filter" => NodeTemplate::Filter, - "Gain" => NodeTemplate::Gain, - "Echo" | "Delay" => NodeTemplate::Echo, - "Reverb" => NodeTemplate::Reverb, - "Chorus" => NodeTemplate::Chorus, - "Flanger" => NodeTemplate::Flanger, - "Phaser" => NodeTemplate::Phaser, - "Distortion" => NodeTemplate::Distortion, - "BitCrusher" => NodeTemplate::BitCrusher, - "Compressor" => NodeTemplate::Compressor, - "Limiter" => NodeTemplate::Limiter, - "EQ" => NodeTemplate::Eq, - "Pan" => NodeTemplate::Pan, - "RingModulator" => NodeTemplate::RingModulator, - "Vocoder" => NodeTemplate::Vocoder, - "ADSR" => NodeTemplate::Adsr, - "LFO" => NodeTemplate::Lfo, - "Mixer" => NodeTemplate::Mixer, - "Splitter" => NodeTemplate::Splitter, - "Constant" => NodeTemplate::Constant, - "MidiToCV" => NodeTemplate::MidiToCv, - "AudioToCV" => NodeTemplate::AudioToCv, - "Math" => NodeTemplate::Math, - "SampleHold" => NodeTemplate::SampleHold, - "SlewLimiter" => NodeTemplate::SlewLimiter, - "Quantizer" => NodeTemplate::Quantizer, - "EnvelopeFollower" => NodeTemplate::EnvelopeFollower, - "BPMDetector" => NodeTemplate::BpmDetector, - "Mod" => NodeTemplate::Mod, - "Oscilloscope" => NodeTemplate::Oscilloscope, - "VoiceAllocator" => NodeTemplate::VoiceAllocator, - "Group" => NodeTemplate::Group, - "TemplateInput" => NodeTemplate::TemplateInput, - "TemplateOutput" => NodeTemplate::TemplateOutput, - "AudioOutput" => NodeTemplate::AudioOutput, - _ => { + let node_template = match Self::backend_type_to_template(&node.node_type) { + Some(t) => t, + None => { eprintln!("Unknown node type: {}", node.node_type); continue; } }; - use egui_node_graph2::Node; - let frontend_id = self.state.graph.nodes.insert(Node { - id: NodeId::default(), - label: node.node_type.clone(), - inputs: vec![], - outputs: vec![], - user_data: NodeData { template: node_template }, - }); - - node_template.build_node(&mut self.state.graph, &mut self.user_state, frontend_id); - - self.state.node_positions.insert( - frontend_id, - egui::pos2(node.position.0, node.position.1), - ); - - self.state.node_order.push(frontend_id); - - let backend_id = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(node.id as usize)); - self.node_id_map.insert(frontend_id, backend_id); - self.backend_to_frontend_map.insert(backend_id, frontend_id); - - // Set parameter values from backend - if let Some(node_data) = self.state.graph.nodes.get(frontend_id) { - let input_ids: Vec = node_data.inputs.iter().map(|(_, id)| *id).collect(); - for input_id in input_ids { - if let Some(input_param) = self.state.graph.inputs.get_mut(input_id) { - if let ValueType::Float { value, backend_param_id: Some(pid), .. } = &mut input_param.value { - if let Some(&backend_value) = node.parameters.get(pid) { - *value = backend_value as f32; - } - } - } - } - } + self.add_node_to_editor(node_template, &node.node_type, node.position, node.id, &node.parameters); } // Create connections in frontend for conn in &graph_state.connections { - let from_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(conn.from_node as usize)); - let to_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(conn.to_node as usize)); + self.add_connection_to_editor(conn.from_node, conn.from_port, conn.to_node, conn.to_port); + } - if let (Some(&from_id), Some(&to_id)) = ( - self.backend_to_frontend_map.get(&from_backend), - self.backend_to_frontend_map.get(&to_backend), - ) { - if let Some(from_node) = self.state.graph.nodes.get(from_id) { - if let Some((_name, output_id)) = from_node.outputs.get(conn.from_port) { - if let Some(to_node) = self.state.graph.nodes.get(to_id) { - if let Some((_name, input_id)) = to_node.inputs.get(conn.to_port) { - let max_conns = self.state.graph.inputs.get(*input_id) - .and_then(|p| p.max_connections) - .map(|n| n.get() as usize) - .unwrap_or(usize::MAX); - - let current_count = self.state.graph.connections.get(*input_id) - .map(|c| c.len()) - .unwrap_or(0); - - if current_count < max_conns { - if let Some(connections) = self.state.graph.connections.get_mut(*input_id) { - connections.push(*output_id); - } else { - self.state.graph.connections.insert(*input_id, vec![*output_id]); - } - } - } - } - } + // Restore groups from preset + self.groups.clear(); + self.group_placeholder_map.clear(); + self.next_group_id = 1; + if !graph_state.groups.is_empty() { + for sg in &graph_state.groups { + let group = GroupDef { + id: sg.id, + name: sg.name.clone(), + member_nodes: sg.member_nodes.clone(), + position: sg.position, + boundary_inputs: sg.boundary_inputs.iter().map(|bc| BoundaryConnection { + external_node: bc.external_node, + external_port: bc.external_port, + internal_node: bc.internal_node, + internal_port: bc.internal_port, + port_name: bc.port_name.clone(), + data_type: match bc.data_type.as_str() { + "Midi" => DataType::Midi, + "CV" => DataType::CV, + _ => DataType::Audio, + }, + }).collect(), + boundary_outputs: sg.boundary_outputs.iter().map(|bc| BoundaryConnection { + external_node: bc.external_node, + external_port: bc.external_port, + internal_node: bc.internal_node, + internal_port: bc.internal_port, + port_name: bc.port_name.clone(), + data_type: match bc.data_type.as_str() { + "Midi" => DataType::Midi, + "CV" => DataType::CV, + _ => DataType::Audio, + }, + }).collect(), + parent_group_id: sg.parent_group_id, + }; + if sg.id >= self.next_group_id { + self.next_group_id = sg.id + 1; } + self.groups.push(group); } + // Rebuild the view to show group placeholders instead of member nodes + self.rebuild_view(); } Ok(()) } - /// Get the VA backend node ID if we're editing inside a VoiceAllocator template - fn va_context(&self) -> Option { - match self.current_subgraph()? { - SubgraphContext::VoiceAllocator { backend_id, .. } => { - let BackendNodeId::Audio(idx) = *backend_id; - Some(idx.index() as u32) - } - _ => None, + /// Serialize a GroupDef to backend format + fn serialize_group(g: &GroupDef) -> daw_backend::audio::node_graph::SerializedGroup { + daw_backend::audio::node_graph::SerializedGroup { + id: g.id, + name: g.name.clone(), + member_nodes: g.member_nodes.clone(), + position: g.position, + boundary_inputs: g.boundary_inputs.iter().map(|bc| { + daw_backend::audio::node_graph::SerializedBoundaryConnection { + external_node: bc.external_node, + external_port: bc.external_port, + internal_node: bc.internal_node, + internal_port: bc.internal_port, + port_name: bc.port_name.clone(), + data_type: match bc.data_type { + DataType::Audio => "Audio".to_string(), + DataType::Midi => "Midi".to_string(), + DataType::CV => "CV".to_string(), + }, + } + }).collect(), + boundary_outputs: g.boundary_outputs.iter().map(|bc| { + daw_backend::audio::node_graph::SerializedBoundaryConnection { + external_node: bc.external_node, + external_port: bc.external_port, + internal_node: bc.internal_node, + internal_port: bc.internal_port, + port_name: bc.port_name.clone(), + data_type: match bc.data_type { + DataType::Audio => "Audio".to_string(), + DataType::Midi => "Midi".to_string(), + DataType::CV => "CV".to_string(), + }, + } + }).collect(), + parent_group_id: g.parent_group_id, } } + /// Serialize frontend groups to backend format and send to backend for persistence + fn sync_groups_to_backend(&self, shared: &crate::panes::SharedPaneState) { + let Some(track_id) = self.track_id else { return }; + let Some(&backend_track_id) = shared.layer_to_track_map.get(&track_id) else { return }; + let Some(audio_controller) = &shared.audio_controller else { return }; + + let serialized: Vec<_> = self.groups.iter().map(Self::serialize_group).collect(); + + let mut controller = audio_controller.lock().unwrap(); + if let Some(va_id) = self.va_context() { + controller.graph_set_groups_in_template(backend_track_id, va_id, serialized); + } else { + controller.graph_set_groups(backend_track_id, serialized); + } + } + + /// Get the VA backend node ID if we're editing inside a VoiceAllocator template. + /// Searches the entire subgraph stack, not just the top — so a Group inside a VA + /// still finds the VA context. + fn va_context(&self) -> Option { + for frame in self.subgraph_stack.iter().rev() { + if let SubgraphContext::VoiceAllocator { backend_id, .. } = &frame.context { + let BackendNodeId::Audio(idx) = *backend_id; + return Some(idx.index() as u32); + } + } + None + } + /// Whether we're currently editing inside a subgraph fn in_subgraph(&self) -> bool { !self.subgraph_stack.is_empty() } - /// Get the current subgraph context (top of stack) - fn current_subgraph(&self) -> Option<&SubgraphContext> { - self.subgraph_stack.last().map(|f| &f.context) + /// Get the GroupId of the current group scope (if inside a group), for filtering sub-groups. + fn current_group_scope(&self) -> Option { + self.subgraph_stack.last().and_then(|frame| { + if let SubgraphContext::Group { group_id, .. } = &frame.context { + Some(*group_id) + } else { + None + } + }) } /// Build breadcrumb segments for the current subgraph stack @@ -1142,6 +1249,671 @@ impl NodeGraphPane { } segments } + + /// Group the currently selected nodes into a new group + fn group_selected_nodes(&mut self, shared: &mut crate::panes::SharedPaneState) { + if self.state.selected_nodes.len() < 2 { + return; + } + + // Don't allow grouping group placeholders + if self.state.selected_nodes.iter().any(|id| self.group_placeholder_map.contains_key(id)) { + return; + } + + // Collect selected backend IDs + let selected_backend_ids: Vec = self.state.selected_nodes.iter() + .filter_map(|fid| self.node_id_map.get(fid)) + .map(|bid| { let BackendNodeId::Audio(idx) = *bid; idx.index() as u32 }) + .collect(); + + if selected_backend_ids.is_empty() { + return; + } + + let selected_set: HashSet = selected_backend_ids.iter().copied().collect(); + + // Find boundary connections by scanning all connections in the editor + let mut boundary_inputs: Vec = Vec::new(); + let mut boundary_outputs: Vec = Vec::new(); + + // Collect connection info: (input_id, vec of output_ids) + let connections: Vec<(InputId, Vec)> = self.state.graph.connections.iter() + .map(|(iid, oids)| (iid, oids.clone())) + .collect(); + + for (input_id, output_ids) in &connections { + let to_node_fid = self.state.graph.inputs.get(*input_id).map(|p| p.node); + for &output_id in output_ids { + let from_node_fid = self.state.graph.outputs.get(output_id).map(|p| p.node); + + if let (Some(from_fid), Some(to_fid)) = (from_node_fid, to_node_fid) { + let from_bid = self.node_id_map.get(&from_fid) + .map(|b| { let BackendNodeId::Audio(idx) = *b; idx.index() as u32 }); + let to_bid = self.node_id_map.get(&to_fid) + .map(|b| { let BackendNodeId::Audio(idx) = *b; idx.index() as u32 }); + + if let (Some(from_b), Some(to_b)) = (from_bid, to_bid) { + let from_in_group = selected_set.contains(&from_b); + let to_in_group = selected_set.contains(&to_b); + + if !from_in_group && to_in_group { + // Boundary input: external → internal + let from_port = self.state.graph.nodes.get(from_fid) + .and_then(|n| n.outputs.iter().position(|(_, id)| *id == output_id)) + .unwrap_or(0); + let to_port = self.state.graph.nodes.get(to_fid) + .and_then(|n| n.inputs.iter().position(|(_, id)| *id == *input_id)) + .unwrap_or(0); + + // Get port name from the input node's input label, and data type + let (port_name, data_type) = self.state.graph.nodes.get(to_fid) + .and_then(|n| n.inputs.get(to_port)) + .map(|(name, iid)| { + let dt = self.state.graph.inputs.get(*iid) + .map(|p| p.typ) + .unwrap_or(DataType::Audio); + (name.clone(), dt) + }) + .unwrap_or_else(|| ("In".to_string(), DataType::Audio)); + + boundary_inputs.push(BoundaryConnection { + external_node: from_b, + external_port: from_port, + internal_node: to_b, + internal_port: to_port, + port_name, + data_type, + }); + } else if from_in_group && !to_in_group { + // Boundary output: internal → external + let from_port = self.state.graph.nodes.get(from_fid) + .and_then(|n| n.outputs.iter().position(|(_, id)| *id == output_id)) + .unwrap_or(0); + let to_port = self.state.graph.nodes.get(to_fid) + .and_then(|n| n.inputs.iter().position(|(_, id)| *id == *input_id)) + .unwrap_or(0); + + // Get port name from the output node's output label, and data type + let (port_name, data_type) = self.state.graph.nodes.get(from_fid) + .and_then(|n| n.outputs.get(from_port)) + .map(|(name, oid)| { + let dt = self.state.graph.outputs.get(*oid) + .map(|p| p.typ) + .unwrap_or(DataType::Audio); + (name.clone(), dt) + }) + .unwrap_or_else(|| ("Out".to_string(), DataType::Audio)); + + boundary_outputs.push(BoundaryConnection { + external_node: to_b, + external_port: to_port, + internal_node: from_b, + internal_port: from_port, + port_name, + data_type, + }); + } + } + } + } + } + + // Calculate average position of selected nodes + let mut sum_x = 0.0f32; + let mut sum_y = 0.0f32; + let mut count = 0; + for &fid in &self.state.selected_nodes { + if let Some(pos) = self.state.node_positions.get(fid) { + sum_x += pos.x; + sum_y += pos.y; + count += 1; + } + } + let position = if count > 0 { + (sum_x / count as f32, sum_y / count as f32) + } else { + (0.0, 0.0) + }; + + // Inherit boundary connections from the parent group for any internal nodes + // that are being included in this sub-group. This handles the case where + // connections pass through the parent's Group Input/Output synthetic nodes + // (which don't have backend IDs and are invisible to the editor scan above). + if let Some(parent_gid) = self.current_group_scope() { + if let Some(parent_group) = self.groups.iter().find(|g| g.id == parent_gid).cloned() { + for bc in &parent_group.boundary_inputs { + if selected_set.contains(&bc.internal_node) { + // Check we don't already have this boundary from the editor scan + let already_exists = boundary_inputs.iter().any(|existing| + existing.internal_node == bc.internal_node && + existing.internal_port == bc.internal_port && + existing.external_node == bc.external_node && + existing.external_port == bc.external_port + ); + if !already_exists { + boundary_inputs.push(bc.clone()); + } + } + } + for bc in &parent_group.boundary_outputs { + if selected_set.contains(&bc.internal_node) { + let already_exists = boundary_outputs.iter().any(|existing| + existing.internal_node == bc.internal_node && + existing.internal_port == bc.internal_port && + existing.external_node == bc.external_node && + existing.external_port == bc.external_port + ); + if !already_exists { + boundary_outputs.push(bc.clone()); + } + } + } + } + } + + let group = GroupDef { + id: self.next_group_id, + name: format!("Group {}", self.next_group_id), + member_nodes: selected_backend_ids, + position, + boundary_inputs, + boundary_outputs, + parent_group_id: self.current_group_scope(), + }; + self.next_group_id += 1; + self.groups.push(group); + + // Rebuild the view to show the group placeholder + self.rebuild_view(); + + // Sync groups to backend for persistence + self.sync_groups_to_backend(shared); + } + + /// Ungroup a group, restoring member nodes to the current view. + /// Also promotes any child groups to the current scope. + fn ungroup(&mut self, group_id: GroupId, shared: &crate::panes::SharedPaneState) { + let parent = self.groups.iter().find(|g| g.id == group_id).and_then(|g| g.parent_group_id); + // Promote child groups: any group whose parent was the ungrouped group + // now becomes a child of the ungrouped group's parent + for g in &mut self.groups { + if g.parent_group_id == Some(group_id) { + g.parent_group_id = parent; + } + } + self.groups.retain(|g| g.id != group_id); + self.rebuild_view(); + self.sync_groups_to_backend(shared); + } + + /// Rebuild the graph view, scope-aware for nested groups. + /// - At top level (no group scope): shows all ungrouped nodes + root group placeholders + /// - Inside a group: shows that group's member nodes (minus sub-group members) + sub-group placeholders + boundary indicators + /// Context-aware: queries the template graph when inside a VA subgraph. + fn rebuild_view(&mut self) { + let backend = match &self.backend { + Some(b) => b, + None => return, + }; + let json = if let Some(va_id) = self.va_context() { + match backend.query_template_state(va_id) { + Ok(json) => json, + Err(e) => { eprintln!("Failed to query template state: {}", e); return; } + } + } else { + match backend.get_state_json() { + Ok(json) => json, + Err(e) => { eprintln!("Failed to query backend: {}", e); return; } + } + }; + + let graph_state: daw_backend::audio::node_graph::GraphPreset = match serde_json::from_str(&json) { + Ok(state) => state, + Err(e) => { eprintln!("Failed to parse graph state: {}", e); return; } + }; + + let current_scope = self.current_group_scope(); + + // Determine which nodes are "in scope" (visible universe) + let scope_members: Option> = current_scope.and_then(|gid| { + self.groups.iter().find(|g| g.id == gid) + .map(|g| g.member_nodes.iter().copied().collect()) + }); + + // Get groups relevant to this scope (direct children) + let relevant_groups: Vec = self.groups.iter() + .filter(|g| g.parent_group_id == current_scope) + .cloned() + .collect(); + + // Build set of node IDs hidden behind sub-group placeholders + let sub_grouped_ids: HashSet = relevant_groups.iter() + .flat_map(|g| g.member_nodes.iter().copied()) + .collect(); + + // Clear editor state + self.state.graph.nodes.clear(); + self.state.graph.inputs.clear(); + self.state.graph.outputs.clear(); + self.state.graph.connections.clear(); + self.state.node_order.clear(); + self.state.node_positions.clear(); + self.state.selected_nodes.clear(); + self.state.connection_in_progress = None; + self.state.ongoing_box_selection = None; + self.node_id_map.clear(); + self.backend_to_frontend_map.clear(); + self.group_placeholder_map.clear(); + self.parameter_values.clear(); + + // Add visible nodes: in scope, not hidden by sub-groups + for node in &graph_state.nodes { + // If inside a group, only include nodes that are members of that group + if let Some(ref members) = scope_members { + if !members.contains(&node.id) { + continue; + } + } + // Skip nodes hidden behind sub-group placeholders + if sub_grouped_ids.contains(&node.id) { + continue; + } + + let node_template = match Self::backend_type_to_template(&node.node_type) { + Some(t) => t, + None => { + eprintln!("Unknown node type: {}", node.node_type); + continue; + } + }; + + self.add_node_to_editor(node_template, &node.node_type, node.position, node.id, &node.parameters); + } + + // Add sub-group placeholder nodes + for group in &relevant_groups { + let frontend_id = self.state.graph.nodes.insert(egui_node_graph2::Node { + id: NodeId::default(), + label: group.name.clone(), + inputs: vec![], + outputs: vec![], + user_data: NodeData { template: NodeTemplate::Group }, + }); + + // Add dynamic input ports based on boundary inputs + for (i, bc) in group.boundary_inputs.iter().enumerate() { + let name = if group.boundary_inputs.len() == 1 { + bc.port_name.clone() + } else { + format!("{} {}", bc.port_name, i + 1) + }; + self.state.graph.add_input_param( + frontend_id, + name.into(), + bc.data_type, + ValueType::float(0.0), + InputParamKind::ConnectionOnly, + true, + ); + } + + // Add dynamic output ports based on boundary outputs + for (i, bc) in group.boundary_outputs.iter().enumerate() { + let name = if group.boundary_outputs.len() == 1 { + bc.port_name.clone() + } else { + format!("{} {}", bc.port_name, i + 1) + }; + self.state.graph.add_output_param(frontend_id, name.into(), bc.data_type); + } + + self.state.node_positions.insert(frontend_id, egui::pos2(group.position.0, group.position.1)); + self.state.node_order.push(frontend_id); + self.group_placeholder_map.insert(frontend_id, group.id); + } + + // Add connections between visible nodes (skip connections involving sub-grouped nodes) + for conn in &graph_state.connections { + // If scoped, both endpoints must be in scope + if let Some(ref members) = scope_members { + if !members.contains(&conn.from_node) || !members.contains(&conn.to_node) { + continue; + } + } + // Skip connections involving sub-grouped nodes + if sub_grouped_ids.contains(&conn.from_node) || sub_grouped_ids.contains(&conn.to_node) { + continue; + } + + self.add_connection_to_editor(conn.from_node, conn.from_port, conn.to_node, conn.to_port); + } + + // If inside a group, add synthetic Group Input / Group Output boundary indicator nodes + // BEFORE wiring sub-group boundaries, so sub-groups can wire to these nodes. + let mut group_input_fid: Option = None; + let mut group_output_fid: Option = None; + let scope_group = current_scope.and_then(|gid| { + self.groups.iter().find(|g| g.id == gid).cloned() + }); + + if let Some(ref scope_group) = scope_group { + // Group Input (for boundary inputs) + if !scope_group.boundary_inputs.is_empty() { + let min_x = graph_state.nodes.iter() + .filter(|n| scope_group.member_nodes.contains(&n.id)) + .map(|n| n.position.0) + .fold(f32::INFINITY, f32::min); + + let gi_fid = self.state.graph.nodes.insert(egui_node_graph2::Node { + id: NodeId::default(), + label: "Group Input".to_string(), + inputs: vec![], + outputs: vec![], + user_data: NodeData { template: NodeTemplate::Group }, + }); + + for bc in &scope_group.boundary_inputs { + self.state.graph.add_output_param(gi_fid, bc.port_name.clone().into(), bc.data_type); + } + + self.state.node_positions.insert(gi_fid, egui::pos2(min_x - 250.0, 0.0)); + self.state.node_order.push(gi_fid); + group_input_fid = Some(gi_fid); + + // Wire Group Input outputs to visible internal nodes (not sub-grouped) + for (port_idx, bc) in scope_group.boundary_inputs.iter().enumerate() { + if sub_grouped_ids.contains(&bc.internal_node) { + continue; // Will be wired through sub-group placeholder below + } + let to_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(bc.internal_node as usize)); + if let Some(&to_fid) = self.backend_to_frontend_map.get(&to_backend) { + if let Some(to_node) = self.state.graph.nodes.get(to_fid) { + if let Some((_name, input_id)) = to_node.inputs.get(bc.internal_port) { + if let Some(gi_node) = self.state.graph.nodes.get(gi_fid) { + if let Some((_name, output_id)) = gi_node.outputs.get(port_idx) { + if let Some(conns) = self.state.graph.connections.get_mut(*input_id) { + conns.push(*output_id); + } else { + self.state.graph.connections.insert(*input_id, vec![*output_id]); + } + } + } + } + } + } + } + } + + // Group Output (for boundary outputs) + if !scope_group.boundary_outputs.is_empty() { + let max_x = graph_state.nodes.iter() + .filter(|n| scope_group.member_nodes.contains(&n.id)) + .map(|n| n.position.0) + .fold(f32::NEG_INFINITY, f32::max); + + let go_fid = self.state.graph.nodes.insert(egui_node_graph2::Node { + id: NodeId::default(), + label: "Group Output".to_string(), + inputs: vec![], + outputs: vec![], + user_data: NodeData { template: NodeTemplate::Group }, + }); + + for bc in &scope_group.boundary_outputs { + self.state.graph.add_input_param( + go_fid, + bc.port_name.clone().into(), + bc.data_type, + ValueType::float(0.0), + InputParamKind::ConnectionOnly, + true, + ); + } + + self.state.node_positions.insert(go_fid, egui::pos2(max_x + 250.0, 0.0)); + self.state.node_order.push(go_fid); + group_output_fid = Some(go_fid); + + // Wire visible internal nodes to Group Output inputs (not sub-grouped) + for (port_idx, bc) in scope_group.boundary_outputs.iter().enumerate() { + if sub_grouped_ids.contains(&bc.internal_node) { + continue; // Will be wired through sub-group placeholder below + } + let from_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(bc.internal_node as usize)); + if let Some(&from_fid) = self.backend_to_frontend_map.get(&from_backend) { + if let Some(from_node) = self.state.graph.nodes.get(from_fid) { + if let Some((_name, output_id)) = from_node.outputs.get(bc.internal_port) { + if let Some(go_node) = self.state.graph.nodes.get(go_fid) { + if let Some((_name, input_id)) = go_node.inputs.get(port_idx) { + self.state.graph.connections.insert(*input_id, vec![*output_id]); + } + } + } + } + } + } + } + } + + // Add boundary connections to/from sub-group placeholders + for group in &relevant_groups { + let placeholder_fid = self.group_placeholder_map.iter() + .find(|(_, gid)| **gid == group.id) + .map(|(fid, _)| *fid); + + if let Some(placeholder_fid) = placeholder_fid { + // Boundary inputs: external_node output → group input port + for (port_idx, bc) in group.boundary_inputs.iter().enumerate() { + let from_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(bc.external_node as usize)); + if let Some(&from_fid) = self.backend_to_frontend_map.get(&from_backend) { + // External node is visible in this scope — wire directly + if let Some(from_node) = self.state.graph.nodes.get(from_fid) { + if let Some((_name, output_id)) = from_node.outputs.get(bc.external_port) { + if let Some(placeholder_node) = self.state.graph.nodes.get(placeholder_fid) { + if let Some((_name, input_id)) = placeholder_node.inputs.get(port_idx) { + self.state.graph.connections.insert(*input_id, vec![*output_id]); + } + } + } + } + } else if let (Some(ref sg), Some(gi_fid)) = (&scope_group, group_input_fid) { + // External node is outside scope — wire from Group Input instead. + // Find which Group Input port matches this boundary connection. + if let Some(gi_port_idx) = sg.boundary_inputs.iter().position(|sbc| + sbc.external_node == bc.external_node && + sbc.external_port == bc.external_port && + sbc.internal_node == bc.internal_node && + sbc.internal_port == bc.internal_port + ) { + if let Some(gi_node) = self.state.graph.nodes.get(gi_fid) { + if let Some((_name, output_id)) = gi_node.outputs.get(gi_port_idx) { + if let Some(placeholder_node) = self.state.graph.nodes.get(placeholder_fid) { + if let Some((_name, input_id)) = placeholder_node.inputs.get(port_idx) { + self.state.graph.connections.insert(*input_id, vec![*output_id]); + } + } + } + } + } + } + } + + // Boundary outputs: group output port → external_node input + for (port_idx, bc) in group.boundary_outputs.iter().enumerate() { + let to_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(bc.external_node as usize)); + if let Some(&to_fid) = self.backend_to_frontend_map.get(&to_backend) { + // External node is visible in this scope — wire directly + if let Some(to_node) = self.state.graph.nodes.get(to_fid) { + if let Some((_name, input_id)) = to_node.inputs.get(bc.external_port) { + if let Some(placeholder_node) = self.state.graph.nodes.get(placeholder_fid) { + if let Some((_name, output_id)) = placeholder_node.outputs.get(port_idx) { + if let Some(connections) = self.state.graph.connections.get_mut(*input_id) { + connections.push(*output_id); + } else { + self.state.graph.connections.insert(*input_id, vec![*output_id]); + } + } + } + } + } + } else if let (Some(ref sg), Some(go_fid)) = (&scope_group, group_output_fid) { + // External node is outside scope — wire to Group Output instead. + if let Some(go_port_idx) = sg.boundary_outputs.iter().position(|sbc| + sbc.external_node == bc.external_node && + sbc.external_port == bc.external_port && + sbc.internal_node == bc.internal_node && + sbc.internal_port == bc.internal_port + ) { + if let Some(placeholder_node) = self.state.graph.nodes.get(placeholder_fid) { + if let Some((_name, output_id)) = placeholder_node.outputs.get(port_idx) { + if let Some(go_node) = self.state.graph.nodes.get(go_fid) { + if let Some((_name, input_id)) = go_node.inputs.get(go_port_idx) { + if let Some(connections) = self.state.graph.connections.get_mut(*input_id) { + connections.push(*output_id); + } else { + self.state.graph.connections.insert(*input_id, vec![*output_id]); + } + } + } + } + } + } + } + } + } + } + } + + /// Helper: map backend node type string to frontend NodeTemplate + fn backend_type_to_template(node_type: &str) -> Option { + match node_type { + "MidiInput" => Some(NodeTemplate::MidiInput), + "AudioInput" => Some(NodeTemplate::AudioInput), + "AutomationInput" => Some(NodeTemplate::AutomationInput), + "Oscillator" => Some(NodeTemplate::Oscillator), + "WavetableOscillator" => Some(NodeTemplate::WavetableOscillator), + "FMSynth" => Some(NodeTemplate::FmSynth), + "NoiseGenerator" => Some(NodeTemplate::Noise), + "SimpleSampler" => Some(NodeTemplate::SimpleSampler), + "MultiSampler" => Some(NodeTemplate::MultiSampler), + "Filter" => Some(NodeTemplate::Filter), + "Gain" => Some(NodeTemplate::Gain), + "Echo" | "Delay" => Some(NodeTemplate::Echo), + "Reverb" => Some(NodeTemplate::Reverb), + "Chorus" => Some(NodeTemplate::Chorus), + "Flanger" => Some(NodeTemplate::Flanger), + "Phaser" => Some(NodeTemplate::Phaser), + "Distortion" => Some(NodeTemplate::Distortion), + "BitCrusher" => Some(NodeTemplate::BitCrusher), + "Compressor" => Some(NodeTemplate::Compressor), + "Limiter" => Some(NodeTemplate::Limiter), + "EQ" => Some(NodeTemplate::Eq), + "Pan" => Some(NodeTemplate::Pan), + "RingModulator" => Some(NodeTemplate::RingModulator), + "Vocoder" => Some(NodeTemplate::Vocoder), + "ADSR" => Some(NodeTemplate::Adsr), + "LFO" => Some(NodeTemplate::Lfo), + "Mixer" => Some(NodeTemplate::Mixer), + "Splitter" => Some(NodeTemplate::Splitter), + "Constant" => Some(NodeTemplate::Constant), + "MidiToCV" => Some(NodeTemplate::MidiToCv), + "AudioToCV" => Some(NodeTemplate::AudioToCv), + "Math" => Some(NodeTemplate::Math), + "SampleHold" => Some(NodeTemplate::SampleHold), + "SlewLimiter" => Some(NodeTemplate::SlewLimiter), + "Quantizer" => Some(NodeTemplate::Quantizer), + "EnvelopeFollower" => Some(NodeTemplate::EnvelopeFollower), + "BPMDetector" => Some(NodeTemplate::BpmDetector), + "Mod" => Some(NodeTemplate::Mod), + "Oscilloscope" => Some(NodeTemplate::Oscilloscope), + "VoiceAllocator" => Some(NodeTemplate::VoiceAllocator), + "Group" => Some(NodeTemplate::Group), + "TemplateInput" => Some(NodeTemplate::TemplateInput), + "TemplateOutput" => Some(NodeTemplate::TemplateOutput), + "AudioOutput" => Some(NodeTemplate::AudioOutput), + _ => None, + } + } + + /// Helper: add a node to the editor state and return its frontend ID + fn add_node_to_editor( + &mut self, + node_template: NodeTemplate, + label: &str, + position: (f32, f32), + backend_node_id: u32, + parameters: &std::collections::HashMap, + ) -> Option { + let frontend_id = self.state.graph.nodes.insert(egui_node_graph2::Node { + id: NodeId::default(), + label: label.to_string(), + inputs: vec![], + outputs: vec![], + user_data: NodeData { template: node_template }, + }); + + node_template.build_node(&mut self.state.graph, &mut self.user_state, frontend_id); + + self.state.node_positions.insert(frontend_id, egui::pos2(position.0, position.1)); + self.state.node_order.push(frontend_id); + + let backend_id = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(backend_node_id as usize)); + self.node_id_map.insert(frontend_id, backend_id); + self.backend_to_frontend_map.insert(backend_id, frontend_id); + + // Set parameter values from backend + if let Some(node_data) = self.state.graph.nodes.get(frontend_id) { + let input_ids: Vec = node_data.inputs.iter().map(|(_, id)| *id).collect(); + for input_id in input_ids { + if let Some(input_param) = self.state.graph.inputs.get_mut(input_id) { + if let ValueType::Float { value, backend_param_id: Some(pid), .. } = &mut input_param.value { + if let Some(&backend_value) = parameters.get(pid) { + *value = backend_value as f32; + } + } + } + } + } + + Some(frontend_id) + } + + /// Helper: add a connection to the editor state + fn add_connection_to_editor(&mut self, from_node: u32, from_port: usize, to_node: u32, to_port: usize) { + let from_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(from_node as usize)); + let to_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(to_node as usize)); + + if let (Some(&from_fid), Some(&to_fid)) = ( + self.backend_to_frontend_map.get(&from_backend), + self.backend_to_frontend_map.get(&to_backend), + ) { + if let Some(from_node) = self.state.graph.nodes.get(from_fid) { + if let Some((_name, output_id)) = from_node.outputs.get(from_port) { + if let Some(to_node) = self.state.graph.nodes.get(to_fid) { + if let Some((_name, input_id)) = to_node.inputs.get(to_port) { + let max_conns = self.state.graph.inputs.get(*input_id) + .and_then(|p| p.max_connections) + .map(|n| n.get() as usize) + .unwrap_or(usize::MAX); + + let current_count = self.state.graph.connections.get(*input_id) + .map(|c| c.len()) + .unwrap_or(0); + + if current_count < max_conns { + if let Some(connections) = self.state.graph.connections.get_mut(*input_id) { + connections.push(*output_id); + } else { + self.state.graph.connections.insert(*input_id, vec![*output_id]); + } + } + } + } + } + } + } + } } impl crate::panes::PaneRenderer for NodeGraphPane { @@ -1174,8 +1946,11 @@ impl crate::panes::PaneRenderer for NodeGraphPane { }; if is_valid_track { - // Reload graph for new track — exit any subgraph editing + // Reload graph for new track — exit any subgraph editing and clear groups self.subgraph_stack.clear(); + self.groups.clear(); + self.next_group_id = 1; + self.group_placeholder_map.clear(); self.track_id = Some(new_track_id); // Recreate backend @@ -1339,9 +2114,214 @@ impl crate::panes::PaneRenderer for NodeGraphPane { ) }; - // Handle graph events and create actions + // Cache node rects for hit-testing, then handle response + self.last_node_rects = graph_response.node_rects.clone(); self.handle_graph_response(graph_response, shared, graph_rect); + // 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()); + if secondary_clicked { + if let Some(cursor_pos) = ui.input(|i| i.pointer.latest_pos()) { + // Hit-test against actual rendered node rects + for (&fid, &node_rect) in &self.last_node_rects { + if node_rect.contains(cursor_pos) { + self.state.node_finder = None; + self.node_context_menu = Some((fid, cursor_pos)); + break; + } + } + } + } + } + + // Draw node context menu + if let Some((ctx_node_id, menu_pos)) = self.node_context_menu { + let is_group = self.group_placeholder_map.contains_key(&ctx_node_id); + let group_id = self.group_placeholder_map.get(&ctx_node_id).copied(); + let mut close_menu = false; + let mut action_delete = false; + let mut action_ungroup = false; + let mut action_rename = false; + + let menu_response = egui::Area::new(ui.id().with("node_context_menu")) + .fixed_pos(menu_pos) + .order(egui::Order::Foreground) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(120.0); + if is_group { + if ui.button("Rename Group").clicked() { + action_rename = true; + close_menu = true; + } + if ui.button("Ungroup").clicked() { + action_ungroup = true; + close_menu = true; + } + ui.separator(); + } + if ui.button("Delete").clicked() { + action_delete = true; + close_menu = true; + } + }); + }); + + // Close menu on click outside the menu area + let menu_rect = menu_response.response.rect; + let clicked_outside = ui.input(|i| { + i.pointer.any_pressed() + && i.pointer.latest_pos() + .map(|p| !menu_rect.contains(p)) + .unwrap_or(false) + }); + if clicked_outside { + close_menu = true; + } + + if action_rename { + if let Some(gid) = group_id { + if let Some(group) = self.groups.iter().find(|g| g.id == gid) { + self.renaming_group = Some((gid, group.name.clone())); + } + } + } + if action_ungroup { + if let Some(gid) = group_id { + self.ungroup(gid, shared); + } + } + if action_delete { + if is_group { + if let Some(gid) = group_id { + self.groups.retain(|g| g.id != gid); + self.rebuild_view(); + self.sync_groups_to_backend(shared); + } + } else { + // Delete the node via the graph - queue the deletion + if let Some(track_id) = self.track_id { + if let Some(&backend_id) = self.node_id_map.get(&ctx_node_id) { + let BackendNodeId::Audio(node_idx) = backend_id; + if let Some(va_id) = self.va_context() { + if let Some(&backend_track_id) = shared.layer_to_track_map.get(&track_id) { + if let Some(audio_controller) = &shared.audio_controller { + let mut controller = audio_controller.lock().unwrap(); + controller.graph_remove_node_from_template( + backend_track_id, va_id, node_idx.index() as u32, + ); + } + } + } else { + let action = Box::new(actions::NodeGraphAction::RemoveNode( + actions::RemoveNodeAction::new(track_id, backend_id) + )); + self.pending_action = Some(action); + } + // Remove from editor state + self.state.graph.nodes.remove(ctx_node_id); + self.node_id_map.remove(&ctx_node_id); + self.backend_to_frontend_map.remove(&backend_id); + } + } + } + } + if close_menu { + self.node_context_menu = None; + } + } + + // Draw group rename popup + if let Some((group_id, ref mut new_name)) = self.renaming_group.clone() { + let mut close_rename = false; + let mut apply_rename = false; + let mut name_buf = new_name.clone(); + + let center = rect.center(); + egui::Area::new(ui.id().with("group_rename_popup")) + .fixed_pos(egui::pos2(center.x - 100.0, center.y - 30.0)) + .order(egui::Order::Foreground) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(200.0); + ui.label("Rename Group:"); + let response = ui.text_edit_singleline(&mut name_buf); + if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + apply_rename = true; + } + ui.horizontal(|ui| { + if ui.button("OK").clicked() { + apply_rename = true; + } + if ui.button("Cancel").clicked() { + close_rename = true; + } + }); + // Auto-focus the text field + response.request_focus(); + }); + }); + + if apply_rename { + if let Some(group) = self.groups.iter_mut().find(|g| g.id == group_id) { + group.name = name_buf.clone(); + } + // Update the placeholder node's label + for (&fid, &gid) in &self.group_placeholder_map { + if gid == group_id { + if let Some(node) = self.state.graph.nodes.get_mut(fid) { + node.label = name_buf; + } + break; + } + } + self.renaming_group = None; + self.sync_groups_to_backend(shared); + } else if close_rename { + self.renaming_group = None; + } else { + self.renaming_group = Some((group_id, name_buf)); + } + } + + // Handle pane-local keyboard shortcuts (only when pointer is over this pane) + if ui.rect_contains_pointer(rect) { + let ctrl_g = ui.input(|i| { + i.key_pressed(egui::Key::G) && (i.modifiers.ctrl || i.modifiers.command) + }); + if ctrl_g && !self.state.selected_nodes.is_empty() { + self.group_selected_nodes(shared); + } + + // Ctrl+Shift+G to ungroup + let ctrl_shift_g = ui.input(|i| { + i.key_pressed(egui::Key::G) && (i.modifiers.ctrl || i.modifiers.command) && i.modifiers.shift + }); + if ctrl_shift_g { + // Ungroup any selected group placeholders + let group_ids_to_ungroup: Vec = self.state.selected_nodes.iter() + .filter_map(|fid| self.group_placeholder_map.get(fid).copied()) + .collect(); + for gid in group_ids_to_ungroup { + self.ungroup(gid, shared); + } + } + + // F2 to rename selected group + let f2 = ui.input(|i| i.key_pressed(egui::Key::F2)); + if f2 && self.renaming_group.is_none() { + // Find the first selected group placeholder + if let Some(group_id) = self.state.selected_nodes.iter() + .find_map(|fid| self.group_placeholder_map.get(fid).copied()) + { + if let Some(group) = self.groups.iter().find(|g| g.id == group_id) { + self.renaming_group = Some((group_id, group.name.clone())); + } + } + } + } + // Check for parameter value changes and send updates to backend self.check_parameter_changes(shared); From 0ff651f4a577174bd8d638bcc55f605836f32bb6 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 16 Feb 2026 04:05:59 -0500 Subject: [PATCH 2/4] Use forked egui to fix wayland/ibus bug --- lightningbeam-ui/Cargo.lock | 20 -------------------- lightningbeam-ui/Cargo.toml | 11 +++++++++++ 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index a4eec0b..65c58a4 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -1798,8 +1798,6 @@ dependencies = [ [[package]] name = "ecolor" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" dependencies = [ "bytemuck", "emath", @@ -1809,8 +1807,6 @@ dependencies = [ [[package]] name = "eframe" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" dependencies = [ "ahash 0.8.12", "bytemuck", @@ -1846,8 +1842,6 @@ dependencies = [ [[package]] name = "egui" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" dependencies = [ "accesskit", "ahash 0.8.12", @@ -1866,8 +1860,6 @@ dependencies = [ [[package]] name = "egui-wgpu" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" dependencies = [ "ahash 0.8.12", "bytemuck", @@ -1886,8 +1878,6 @@ dependencies = [ [[package]] name = "egui-winit" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29" dependencies = [ "accesskit_winit", "arboard", @@ -1917,8 +1907,6 @@ dependencies = [ [[package]] name = "egui_extras" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d01d34e845f01c62e3fded726961092e70417d66570c499b9817ab24674ca4ed" dependencies = [ "ahash 0.8.12", "egui", @@ -1934,8 +1922,6 @@ dependencies = [ [[package]] name = "egui_glow" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb" dependencies = [ "bytemuck", "egui", @@ -1968,8 +1954,6 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "emath" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" dependencies = [ "bytemuck", "serde", @@ -2045,8 +2029,6 @@ dependencies = [ [[package]] name = "epaint" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" dependencies = [ "ab_glyph", "ahash 0.8.12", @@ -2064,8 +2046,6 @@ dependencies = [ [[package]] name = "epaint_default_fonts" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" [[package]] name = "equator" diff --git a/lightningbeam-ui/Cargo.toml b/lightningbeam-ui/Cargo.toml index d01ee5a..e5a67a5 100644 --- a/lightningbeam-ui/Cargo.toml +++ b/lightningbeam-ui/Cargo.toml @@ -69,3 +69,14 @@ opt-level = 2 opt-level = 2 [profile.dev.package.cpal] opt-level = 2 + +# Use local egui fork with ibus/Wayland text input fix +[patch.crates-io] +egui = { path = "../../egui-fork/crates/egui" } +eframe = { path = "../../egui-fork/crates/eframe" } +egui_extras = { path = "../../egui-fork/crates/egui_extras" } +egui-wgpu = { path = "../../egui-fork/crates/egui-wgpu" } +egui-winit = { path = "../../egui-fork/crates/egui-winit" } +epaint = { path = "../../egui-fork/crates/epaint" } +ecolor = { path = "../../egui-fork/crates/ecolor" } +emath = { path = "../../egui-fork/crates/emath" } From 65fa8a39189ffc22a50a09bd922056b121cdb43f Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 16 Feb 2026 06:06:03 -0500 Subject: [PATCH 3/4] Add preset pane --- daw-backend/src/audio/engine.rs | 31 ++ .../src/audio/node_graph/nodes/filter.rs | 21 +- .../audio/node_graph/nodes/voice_allocator.rs | 29 + daw-backend/src/audio/project.rs | 12 + daw-backend/src/command/types.rs | 3 + .../lightningbeam-core/src/pane.rs | 2 +- .../lightningbeam-editor/src/main.rs | 4 +- .../lightningbeam-editor/src/panes/mod.rs | 2 +- .../src/panes/node_graph/graph_data.rs | 70 ++- .../src/panes/node_graph/mod.rs | 94 +++- .../src/panes/preset_browser.rs | 511 +++++++++++++++++- src/assets/instruments/synthesizers/lead.json | 182 ++++--- src/assets/instruments/synthesizers/pad.json | 216 +++++--- 13 files changed, 980 insertions(+), 197 deletions(-) diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 833fae1..a7ebc05 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1970,6 +1970,18 @@ impl Engine { ))), } } + Query::GetVoiceOscilloscopeData(track_id, va_node_id, inner_node_id, sample_count) => { + match self.project.get_voice_oscilloscope_data(track_id, va_node_id, inner_node_id, sample_count) { + Some((audio, cv)) => { + use crate::command::OscilloscopeData; + QueryResponse::OscilloscopeData(Ok(OscilloscopeData { audio, cv })) + } + None => QueryResponse::OscilloscopeData(Err(format!( + "Failed to get voice oscilloscope data from track {} VA {} node {}", + track_id, va_node_id, inner_node_id + ))), + } + } Query::GetMidiClip(_track_id, clip_id) => { // Get MIDI clip data from the pool if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) { @@ -3215,6 +3227,25 @@ impl EngineController { Err("Query timeout".to_string()) } + /// Query oscilloscope data from a node inside a VoiceAllocator's best voice + pub fn query_voice_oscilloscope_data(&mut self, track_id: TrackId, va_node_id: u32, inner_node_id: u32, sample_count: usize) -> Result { + if let Err(_) = self.query_tx.push(Query::GetVoiceOscilloscopeData(track_id, va_node_id, inner_node_id, sample_count)) { + return Err("Failed to send query - queue full".to_string()); + } + + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(100); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::OscilloscopeData(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_micros(50)); + } + + Err("Query timeout".to_string()) + } + /// Query automation keyframes from an AutomationInput node pub fn query_automation_keyframes(&mut self, track_id: TrackId, node_id: u32) -> Result, String> { // Send query diff --git a/daw-backend/src/audio/node_graph/nodes/filter.rs b/daw-backend/src/audio/node_graph/nodes/filter.rs index ca69b12..719460c 100644 --- a/daw-backend/src/audio/node_graph/nodes/filter.rs +++ b/daw-backend/src/audio/node_graph/nodes/filter.rs @@ -29,6 +29,8 @@ pub struct FilterNode { resonance: f32, filter_type: FilterType, sample_rate: u32, + /// Last cutoff frequency applied to filter coefficients (for change detection with CV modulation) + last_applied_cutoff: f32, inputs: Vec, outputs: Vec, parameters: Vec, @@ -62,6 +64,7 @@ impl FilterNode { resonance: 0.707, filter_type: FilterType::Lowpass, sample_rate: 44100, + last_applied_cutoff: 1000.0, inputs, outputs, parameters, @@ -150,11 +153,20 @@ impl AudioNode for FilterNode { output[..len].copy_from_slice(&input[..len]); // Check for CV modulation (modulates cutoff) + // CV input (0..1) scales the cutoff: 0 = 20 Hz, 1 = base cutoff * 2 // Sample CV at the start of the buffer - per-sample would be too expensive - let cutoff_cv = cv_input_or_default(inputs, 1, 0, self.cutoff); - if (cutoff_cv - self.cutoff).abs() > 0.01 { - // CV changed significantly, update filter - let new_cutoff = cutoff_cv.clamp(20.0, 20000.0); + let cutoff_cv_raw = cv_input_or_default(inputs, 1, 0, f32::NAN); + let effective_cutoff = if cutoff_cv_raw.is_nan() { + self.cutoff + } else { + // Map CV (0..1) to frequency range around the base cutoff + // 0.5 = base cutoff, 0 = cutoff / 4, 1 = cutoff * 4 (two octaves each way) + let octave_shift = (cutoff_cv_raw.clamp(0.0, 1.0) - 0.5) * 4.0; + self.cutoff * 2.0_f32.powf(octave_shift) + }; + if (effective_cutoff - self.last_applied_cutoff).abs() > 0.01 { + let new_cutoff = effective_cutoff.clamp(20.0, 20000.0); + self.last_applied_cutoff = new_cutoff; match self.filter_type { FilterType::Lowpass => { self.filter.set_lowpass(new_cutoff, self.resonance, self.sample_rate as f32); @@ -202,6 +214,7 @@ impl AudioNode for FilterNode { resonance: self.resonance, filter_type: self.filter_type, sample_rate: self.sample_rate, + last_applied_cutoff: self.cutoff, inputs: self.inputs.clone(), outputs: self.outputs.clone(), parameters: self.parameters.clone(), diff --git a/daw-backend/src/audio/node_graph/nodes/voice_allocator.rs b/daw-backend/src/audio/node_graph/nodes/voice_allocator.rs index 1221fab..2f2b9dc 100644 --- a/daw-backend/src/audio/node_graph/nodes/voice_allocator.rs +++ b/daw-backend/src/audio/node_graph/nodes/voice_allocator.rs @@ -176,6 +176,35 @@ impl VoiceAllocatorNode { .unwrap_or(0) } + /// Get oscilloscope data from the most relevant voice's subgraph. + /// Priority: first active voice → first releasing voice → first voice. + pub fn get_voice_oscilloscope_data(&self, node_id: u32, sample_count: usize) -> Option<(Vec, Vec)> { + let voice_idx = self.best_voice_index(); + let graph = &self.voice_instances[voice_idx]; + let node_idx = petgraph::stable_graph::NodeIndex::new(node_id as usize); + let audio = graph.get_oscilloscope_data(node_idx, sample_count)?; + let cv = graph.get_oscilloscope_cv_data(node_idx, sample_count).unwrap_or_default(); + Some((audio, cv)) + } + + /// Find the best voice index to observe: first active → first releasing → 0 + fn best_voice_index(&self) -> usize { + // First active (non-releasing) voice + for (i, v) in self.voices[..self.voice_count].iter().enumerate() { + if v.active && !v.releasing { + return i; + } + } + // First releasing voice + for (i, v) in self.voices[..self.voice_count].iter().enumerate() { + if v.active && v.releasing { + return i; + } + } + // Fallback to first voice + 0 + } + /// Find all voices playing a specific note (held, not yet releasing) fn find_voices_for_note_off(&self, note: u8) -> Vec { self.voices[..self.voice_count] diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs index 498a892..8e31bfa 100644 --- a/daw-backend/src/audio/project.rs +++ b/daw-backend/src/audio/project.rs @@ -228,6 +228,18 @@ impl Project { None } + /// Get oscilloscope data from a node inside a VoiceAllocator's best voice + pub fn get_voice_oscilloscope_data(&self, track_id: TrackId, va_node_id: u32, inner_node_id: u32, sample_count: usize) -> Option<(Vec, Vec)> { + if let Some(TrackNode::Midi(track)) = self.tracks.get(&track_id) { + let graph = &track.instrument_graph; + let va_idx = petgraph::stable_graph::NodeIndex::new(va_node_id as usize); + let node = graph.get_node(va_idx)?; + let va = node.as_any().downcast_ref::()?; + return va.get_voice_oscilloscope_data(inner_node_id, sample_count); + } + None + } + /// Get all root-level track IDs pub fn root_tracks(&self) -> &[TrackId] { &self.root_tracks diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 21f0a63..44bfa31 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -319,6 +319,9 @@ pub enum Query { GetTemplateState(TrackId, u32), /// Get oscilloscope data from a node (track_id, node_id, sample_count) GetOscilloscopeData(TrackId, u32, usize), + /// Get oscilloscope data from a node inside a VoiceAllocator's best voice + /// (track_id, va_node_id, inner_node_id, sample_count) + GetVoiceOscilloscopeData(TrackId, u32, u32, usize), /// Get MIDI clip data (track_id, clip_id) GetMidiClip(TrackId, MidiClipId), /// Get keyframes from an AutomationInput node (track_id, node_id) diff --git a/lightningbeam-ui/lightningbeam-core/src/pane.rs b/lightningbeam-ui/lightningbeam-core/src/pane.rs index ceb8f87..c2308e3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/pane.rs +++ b/lightningbeam-ui/lightningbeam-core/src/pane.rs @@ -49,7 +49,7 @@ impl PaneType { PaneType::PianoRoll => "Piano Roll", PaneType::VirtualPiano => "Virtual Piano", PaneType::NodeEditor => "Node Editor", - PaneType::PresetBrowser => "Preset Browser", + PaneType::PresetBrowser => "Instrument Browser", PaneType::AssetLibrary => "Asset Library", PaneType::ShaderEditor => "Shader Editor", } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index d18e4f9..de2e6e5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -4428,7 +4428,7 @@ impl eframe::App for EditorApp { pending_menu_actions: &mut pending_menu_actions, clipboard_manager: &mut self.clipboard_manager, waveform_stereo: self.config.waveform_stereo, - project_generation: self.project_generation, + project_generation: &mut self.project_generation, }; render_layout_node( @@ -4704,7 +4704,7 @@ struct RenderContext<'a> { /// Whether to show waveforms as stacked stereo waveform_stereo: bool, /// Project generation counter (incremented on load) - project_generation: u64, + project_generation: &'a mut u64, } /// Recursively render a layout node with drag support diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 3cfa055..a5f1614 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -218,7 +218,7 @@ pub struct SharedPaneState<'a> { /// Whether to show waveforms as stacked stereo (true) or combined mono (false) pub waveform_stereo: bool, /// Generation counter - incremented on project load to force reloads - pub project_generation: u64, + pub project_generation: &'a mut u64, } /// Trait for pane rendering 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 f0e7485..c0f793e 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 @@ -5,6 +5,7 @@ use eframe::egui; use egui_node_graph2::*; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; /// Signal types for audio node graph #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -136,10 +137,18 @@ pub struct NodeData { pub template: NodeTemplate, } +/// Cached oscilloscope waveform data for rendering in node body +pub struct OscilloscopeCache { + pub audio: Vec, + pub cv: Vec, +} + /// Custom graph state - can track selected nodes, etc. #[derive(Default)] pub struct GraphState { pub active_node: Option, + /// Oscilloscope data cached per node, populated before draw_graph_editor() + pub oscilloscope_data: HashMap, } /// User response type (empty for now) @@ -782,15 +791,52 @@ impl NodeDataTrait for NodeData { fn bottom_ui( &self, ui: &mut egui::Ui, - _node_id: NodeId, + node_id: NodeId, _graph: &Graph, - _user_state: &mut Self::UserState, + user_state: &mut Self::UserState, ) -> Vec> where Self::Response: UserResponseTrait, { - // No custom UI for now - ui.label(""); + if self.template == NodeTemplate::Oscilloscope { + let size = egui::vec2(200.0, 80.0); + let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); + let painter = ui.painter_at(rect); + + // Background + painter.rect_filled(rect, 2.0, egui::Color32::from_rgb(0x1a, 0x1a, 0x1a)); + + // Center line + let center_y = rect.center().y; + painter.line_segment( + [egui::pos2(rect.left(), center_y), egui::pos2(rect.right(), center_y)], + egui::Stroke::new(1.0, egui::Color32::from_rgb(0x2a, 0x2a, 0x2a)), + ); + + if let Some(cache) = user_state.oscilloscope_data.get(&node_id) { + // Draw audio waveform (green) + if cache.audio.len() >= 2 { + let points: Vec = cache.audio.iter().enumerate().map(|(i, &sample)| { + let x = rect.left() + (i as f32 / (cache.audio.len() - 1) as f32) * rect.width(); + let y = center_y - sample.clamp(-1.0, 1.0) * (rect.height() / 2.0); + egui::pos2(x, y) + }).collect(); + painter.add(egui::Shape::line(points, egui::Stroke::new(1.5, egui::Color32::from_rgb(0x4C, 0xAF, 0x50)))); + } + + // Draw CV waveform (orange) if present + if cache.cv.len() >= 2 { + let points: Vec = cache.cv.iter().enumerate().map(|(i, &sample)| { + let x = rect.left() + (i as f32 / (cache.cv.len() - 1) as f32) * rect.width(); + let y = center_y - sample.clamp(-1.0, 1.0) * (rect.height() / 2.0); + egui::pos2(x, y) + }).collect(); + painter.add(egui::Shape::line(points, egui::Stroke::new(1.5, egui::Color32::from_rgb(0xFF, 0x98, 0x00)))); + } + } + } else { + ui.label(""); + } vec![] } } @@ -801,6 +847,22 @@ pub struct AllNodeTemplates; /// Iterator for subgraph node templates (includes TemplateInput/Output) pub struct SubgraphNodeTemplates; +/// Node templates available inside a VoiceAllocator subgraph (no nested VA) +pub struct VoiceAllocatorNodeTemplates; + +impl NodeTemplateIter for VoiceAllocatorNodeTemplates { + type Item = NodeTemplate; + + fn all_kinds(&self) -> Vec { + let mut templates = AllNodeTemplates.all_kinds(); + // VA nodes can't be nested — signals inside a VA are monophonic + templates.retain(|t| *t != NodeTemplate::VoiceAllocator); + templates.push(NodeTemplate::TemplateInput); + templates.push(NodeTemplate::TemplateOutput); + templates + } +} + impl NodeTemplateIter for SubgraphNodeTemplates { type Item = NodeTemplate; 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 f9e3e66..e865e7b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -9,7 +9,7 @@ pub mod graph_data; pub mod node_types; use backend::{BackendNodeId, GraphBackend}; -use graph_data::{AllNodeTemplates, SubgraphNodeTemplates, DataType, GraphState, NodeData, NodeTemplate, ValueType}; +use graph_data::{AllNodeTemplates, SubgraphNodeTemplates, VoiceAllocatorNodeTemplates, DataType, GraphState, NodeData, NodeTemplate, ValueType}; use super::NodePath; use eframe::egui; use egui_node_graph2::*; @@ -136,6 +136,11 @@ pub struct NodeGraphPane { node_context_menu: Option<(NodeId, egui::Pos2)>, /// Cached node screen rects from last frame (for hit-testing) last_node_rects: std::collections::HashMap, + + /// Last time we polled oscilloscope data (~20 FPS) + last_oscilloscope_poll: std::time::Instant, + /// Backend track ID (u32) for oscilloscope queries + backend_track_id: Option, } impl NodeGraphPane { @@ -162,6 +167,8 @@ impl NodeGraphPane { renaming_group: None, node_context_menu: None, last_node_rects: HashMap::new(), + last_oscilloscope_poll: std::time::Instant::now(), + backend_track_id: None, } } @@ -196,6 +203,8 @@ impl NodeGraphPane { renaming_group: None, node_context_menu: None, last_node_rects: HashMap::new(), + last_oscilloscope_poll: std::time::Instant::now(), + backend_track_id: Some(backend_track_id), }; // Load existing graph from backend @@ -1227,6 +1236,13 @@ impl NodeGraphPane { !self.subgraph_stack.is_empty() } + /// True if any frame in the subgraph stack is a VoiceAllocator + fn inside_voice_allocator(&self) -> bool { + self.subgraph_stack.iter().any(|frame| { + matches!(&frame.context, SubgraphContext::VoiceAllocator { .. }) + }) + } + /// Get the GroupId of the current group scope (if inside a group), for filtering sub-groups. fn current_group_scope(&self) -> Option { self.subgraph_stack.last().and_then(|frame| { @@ -1926,9 +1942,9 @@ impl crate::panes::PaneRenderer for NodeGraphPane { ) { // Check if we need to reload for a different track or project reload let current_track = *shared.active_layer_id; - let generation_changed = shared.project_generation != self.last_project_generation; + let generation_changed = *shared.project_generation != self.last_project_generation; if generation_changed { - self.last_project_generation = shared.project_generation; + self.last_project_generation = *shared.project_generation; } // If selected track changed or project was reloaded, reload the graph @@ -1954,6 +1970,7 @@ impl crate::panes::PaneRenderer for NodeGraphPane { self.track_id = Some(new_track_id); // Recreate backend + self.backend_track_id = Some(backend_track_id); self.backend = Some(Box::new(audio_backend::AudioGraphBackend::new( backend_track_id, (*audio_controller).clone(), @@ -1987,6 +2004,68 @@ impl crate::panes::PaneRenderer for NodeGraphPane { painter.galley(text_pos, galley, text_color); return; } + // Poll oscilloscope data at ~20 FPS + let has_oscilloscopes; + if self.last_oscilloscope_poll.elapsed() >= std::time::Duration::from_millis(50) { + self.last_oscilloscope_poll = std::time::Instant::now(); + + // Find all Oscilloscope nodes in the current graph + let oscilloscope_nodes: Vec<(NodeId, u32)> = self.state.graph.iter_nodes() + .filter(|&node_id| { + self.state.graph.nodes.get(node_id) + .map(|n| n.user_data.template == NodeTemplate::Oscilloscope) + .unwrap_or(false) + }) + .filter_map(|node_id| { + self.node_id_map.get(&node_id).and_then(|backend_id| { + match backend_id { + BackendNodeId::Audio(idx) => Some((node_id, idx.index() as u32)), + } + }) + }) + .collect(); + + has_oscilloscopes = !oscilloscope_nodes.is_empty(); + + if has_oscilloscopes { + if let (Some(backend_track_id), Some(audio_controller)) = (self.backend_track_id, &shared.audio_controller) { + // Check if we're inside a VoiceAllocator subgraph + let va_backend_id = self.subgraph_stack.iter().rev().find_map(|frame| { + if let SubgraphContext::VoiceAllocator { backend_id } = &frame.context { + match backend_id { + BackendNodeId::Audio(idx) => Some(idx.index() as u32), + } + } else { + None + } + }); + + let mut controller = audio_controller.lock().unwrap(); + for (node_id, backend_node_id) in oscilloscope_nodes { + let result = if let Some(va_id) = va_backend_id { + controller.query_voice_oscilloscope_data(backend_track_id, va_id, backend_node_id, 4800) + } else { + controller.query_oscilloscope_data(backend_track_id, backend_node_id, 4800) + }; + if let Ok(data) = result { + self.user_state.oscilloscope_data.insert(node_id, graph_data::OscilloscopeCache { + audio: data.audio, + cv: data.cv, + }); + } + } + } + } + } else { + // Between polls, check if we have cached oscilloscope data + has_oscilloscopes = !self.user_state.oscilloscope_data.is_empty(); + } + + // Continuously repaint when oscilloscopes are present + if has_oscilloscopes { + ui.ctx().request_repaint(); + } + // Get colors from theme let bg_style = shared.theme.style(".node-graph-background", ui.ctx()); let grid_style = shared.theme.style(".node-graph-grid", ui.ctx()); @@ -2098,7 +2177,14 @@ impl crate::panes::PaneRenderer for NodeGraphPane { Self::draw_dot_grid_background(ui, graph_rect, bg_color, grid_color, pan_zoom); // Draw the graph editor with context-aware node templates - let graph_response = if self.in_subgraph() { + let graph_response = if self.inside_voice_allocator() { + self.state.draw_graph_editor( + ui, + VoiceAllocatorNodeTemplates, + &mut self.user_state, + Vec::default(), + ) + } else if self.in_subgraph() { self.state.draw_graph_editor( ui, SubgraphNodeTemplates, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/preset_browser.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/preset_browser.rs index d68fe55..d3570fb 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/preset_browser.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/preset_browser.rs @@ -1,45 +1,512 @@ -/// Preset Browser pane - asset and preset library +/// Instrument Browser pane — browse, search, load, and save instrument presets /// -/// This will eventually show a file browser for presets. -/// For now, it's a placeholder. +/// Scans factory presets from `src/assets/instruments/` organized by category. +/// Presets are loaded into the currently selected track's audio graph. use eframe::egui; +use std::path::PathBuf; use super::{NodePath, PaneRenderer, SharedPaneState}; -pub struct PresetBrowserPane {} +/// Metadata extracted from a preset file +struct PresetInfo { + name: String, + path: PathBuf, + category: String, + description: String, + author: String, + tags: Vec, + is_factory: bool, +} -impl PresetBrowserPane { - pub fn new() -> Self { - Self {} +/// State for the save-preset dialog +struct SaveDialogState { + name: String, + description: String, + tags_str: String, +} + +impl Default for SaveDialogState { + fn default() -> Self { + Self { + name: String::new(), + description: String::new(), + tags_str: String::new(), + } } } +pub struct PresetBrowserPane { + presets: Vec, + search_query: String, + /// Index into `self.presets` of the currently selected preset + selected_index: Option, + selected_category: Option, + needs_reload: bool, + save_dialog: Option, + /// Sorted unique category names extracted from presets + categories: Vec, +} + +impl PresetBrowserPane { + pub fn new() -> Self { + Self { + presets: Vec::new(), + search_query: String::new(), + selected_index: None, + selected_category: None, + needs_reload: true, + save_dialog: None, + categories: Vec::new(), + } + } + + /// Scan preset directories and populate the preset list + fn scan_presets(&mut self) { + self.presets.clear(); + self.categories.clear(); + + // Factory presets: resolve from CARGO_MANIFEST_DIR (lightningbeam-editor crate) + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let factory_dir = manifest_dir.join("../../src/assets/instruments"); + + if let Ok(factory_dir) = factory_dir.canonicalize() { + self.scan_directory(&factory_dir, &factory_dir, true); + } + + // Sort presets alphabetically by name within each category + self.presets.sort_by(|a, b| { + a.category.cmp(&b.category).then(a.name.cmp(&b.name)) + }); + + // Extract unique categories + let mut cats: Vec = self.presets.iter() + .map(|p| p.category.clone()) + .collect(); + cats.sort(); + cats.dedup(); + self.categories = cats; + + self.needs_reload = false; + } + + /// Recursively scan a directory for .json preset files + fn scan_directory(&mut self, dir: &std::path::Path, base_dir: &std::path::Path, is_factory: bool) { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + self.scan_directory(&path, base_dir, is_factory); + } else if path.extension().is_some_and(|e| e == "json") { + if let Some(info) = self.load_preset_info(&path, base_dir, is_factory) { + self.presets.push(info); + } + } + } + } + + /// Load metadata from a preset JSON file + fn load_preset_info(&self, path: &std::path::Path, base_dir: &std::path::Path, is_factory: bool) -> Option { + let contents = std::fs::read_to_string(path).ok()?; + let preset: daw_backend::audio::node_graph::GraphPreset = + serde_json::from_str(&contents).ok()?; + + // Category = first directory component relative to base_dir + let relative = path.strip_prefix(base_dir).ok()?; + let category = relative.components().next() + .and_then(|c| c.as_os_str().to_str()) + .unwrap_or("other") + .to_string(); + + Some(PresetInfo { + name: preset.metadata.name, + path: path.to_path_buf(), + category, + description: preset.metadata.description, + author: preset.metadata.author, + tags: preset.metadata.tags, + is_factory, + }) + } + + /// Get indices of presets matching the current search query and category filter + fn filtered_indices(&self) -> Vec { + let query = self.search_query.to_lowercase(); + self.presets.iter().enumerate() + .filter(|(_, p)| { + // Category filter + if let Some(ref cat) = self.selected_category { + if &p.category != cat { + return false; + } + } + // Search filter + if !query.is_empty() { + let name_match = p.name.to_lowercase().contains(&query); + let desc_match = p.description.to_lowercase().contains(&query); + let tag_match = p.tags.iter().any(|t| t.to_lowercase().contains(&query)); + if !name_match && !desc_match && !tag_match { + return false; + } + } + true + }) + .map(|(i, _)| i) + .collect() + } + + /// Load the selected preset into the current track + fn load_preset(&self, preset_index: usize, shared: &mut SharedPaneState) { + let preset = &self.presets[preset_index]; + + let track_id = match shared.active_layer_id.and_then(|lid| shared.layer_to_track_map.get(&lid)) { + Some(&tid) => tid, + None => return, + }; + + if let Some(audio_controller) = &shared.audio_controller { + let mut controller = audio_controller.lock().unwrap(); + controller.graph_load_preset(track_id, preset.path.to_string_lossy().to_string()); + } + + *shared.project_generation += 1; + } + + /// Render the save preset dialog + fn render_save_dialog(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) { + let dialog = match &mut self.save_dialog { + Some(d) => d, + None => return, + }; + + ui.add_space(8.0); + ui.heading("Save Preset"); + ui.add_space(4.0); + + ui.horizontal(|ui| { + ui.label("Name:"); + ui.text_edit_singleline(&mut dialog.name); + }); + + ui.add_space(4.0); + ui.label("Description:"); + ui.add(egui::TextEdit::multiline(&mut dialog.description) + .desired_rows(3) + .desired_width(f32::INFINITY)); + + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label("Tags:"); + ui.text_edit_singleline(&mut dialog.tags_str); + }); + ui.label(egui::RichText::new("Comma-separated, e.g. bass, synth, warm") + .small() + .color(ui.visuals().weak_text_color())); + + ui.add_space(8.0); + let name_valid = !dialog.name.trim().is_empty(); + let mut do_save = false; + let mut do_cancel = false; + ui.horizontal(|ui| { + if ui.add_enabled(name_valid, egui::Button::new("Save")).clicked() { + do_save = true; + } + if ui.button("Cancel").clicked() { + do_cancel = true; + } + }); + + // Act after dialog borrow is released + if do_save { + self.do_save_preset(shared); + } else if do_cancel { + self.save_dialog = None; + } + } + + /// Execute the save action + fn do_save_preset(&mut self, shared: &mut SharedPaneState) { + let dialog = match self.save_dialog.take() { + Some(d) => d, + None => return, + }; + + let track_id = match shared.active_layer_id.and_then(|lid| shared.layer_to_track_map.get(&lid)) { + Some(&tid) => tid, + None => return, + }; + + let name = dialog.name.trim().to_string(); + let description = dialog.description.trim().to_string(); + let tags: Vec = dialog.tags_str.split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + + // Save to user presets directory + let save_dir = user_presets_dir(); + if let Err(e) = std::fs::create_dir_all(&save_dir) { + eprintln!("Failed to create presets directory: {}", e); + return; + } + + let filename = sanitize_filename(&name); + let save_path = save_dir.join(format!("{}.json", filename)); + + if let Some(audio_controller) = &shared.audio_controller { + let mut controller = audio_controller.lock().unwrap(); + controller.graph_save_preset( + track_id, + save_path.to_string_lossy().to_string(), + name, + description, + tags, + ); + } + + self.needs_reload = true; + } +} + +/// Get the user presets directory ($XDG_DATA_HOME/lightningbeam/presets or ~/.local/share/lightningbeam/presets) +fn user_presets_dir() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + PathBuf::from(xdg).join("lightningbeam").join("presets") + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home).join(".local/share/lightningbeam/presets") + } else { + PathBuf::from("presets") + } +} + +/// Sanitize a string for use as a filename +fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' { c } else { '_' }) + .collect::() + .trim() + .to_string() +} + impl PaneRenderer for PresetBrowserPane { + fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let has_track = shared.active_layer_id + .and_then(|lid| shared.layer_to_track_map.get(&lid)) + .is_some(); + if ui.add_enabled(has_track, egui::Button::new("Save")).clicked() { + self.save_dialog = Some(SaveDialogState::default()); + } + }); + true + } + fn render_content( &mut self, ui: &mut egui::Ui, rect: egui::Rect, _path: &NodePath, - _shared: &mut SharedPaneState, + shared: &mut SharedPaneState, ) { - // Placeholder rendering - ui.painter().rect_filled( - rect, - 0.0, - egui::Color32::from_rgb(50, 45, 30), - ); + if self.needs_reload { + self.scan_presets(); + } - let text = "Preset Browser\n(TODO: Implement file browser)"; - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - text, - egui::FontId::proportional(16.0), - egui::Color32::from_gray(150), + // Background + let bg_style = shared.theme.style(".pane-content", ui.ctx()); + let bg_color = bg_style.background_color.unwrap_or(egui::Color32::from_rgb(47, 47, 47)); + ui.painter().rect_filled(rect, 0.0, bg_color); + + let text_color = shared.theme.style(".text-primary", ui.ctx()) + .text_color.unwrap_or(egui::Color32::from_gray(246)); + let text_secondary = shared.theme.style(".text-secondary", ui.ctx()) + .text_color.unwrap_or(egui::Color32::from_gray(170)); + + let content_rect = rect.shrink(4.0); + let mut content_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(content_rect) + .layout(egui::Layout::top_down(egui::Align::LEFT)), ); + let ui = &mut content_ui; + + // Save dialog takes over the content area + if self.save_dialog.is_some() { + self.render_save_dialog(ui, shared); + return; + } + + // Search bar + ui.horizontal(|ui| { + ui.label("Search:"); + ui.text_edit_singleline(&mut self.search_query); + }); + + ui.add_space(4.0); + + // Category chips + ui.horizontal_wrapped(|ui| { + let all_selected = self.selected_category.is_none(); + if ui.selectable_label(all_selected, "All").clicked() { + self.selected_category = None; + self.selected_index = None; + } + for cat in &self.categories.clone() { + let is_selected = self.selected_category.as_ref() == Some(cat); + let display = capitalize_first(cat); + if ui.selectable_label(is_selected, &display).clicked() { + if is_selected { + self.selected_category = None; + } else { + self.selected_category = Some(cat.clone()); + } + self.selected_index = None; + } + } + }); + + ui.separator(); + + // Preset list + let filtered = self.filtered_indices(); + + if filtered.is_empty() { + ui.centered_and_justified(|ui| { + ui.label(egui::RichText::new("No presets found") + .color(text_secondary)); + }); + return; + } + + let mut load_index = None; + let mut delete_path = None; + + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + let mut new_selection = self.selected_index; + + for &idx in &filtered { + let preset = &self.presets[idx]; + let is_selected = self.selected_index == Some(idx); + + let response = ui.push_id(idx, |ui| { + let frame = egui::Frame::NONE + .inner_margin(egui::Margin::same(6)) + .corner_radius(4.0); + + let mut button_clicked = false; + + let frame_response = frame.show(ui, |ui| { + ui.set_min_width(ui.available_width()); + + ui.label( + egui::RichText::new(&preset.name).strong().color(text_color) + ); + + if is_selected { + if !preset.description.is_empty() { + ui.label(egui::RichText::new(&preset.description) + .color(text_secondary) + .small()); + } + + if !preset.tags.is_empty() { + ui.horizontal_wrapped(|ui| { + for tag in &preset.tags { + let tag_frame = egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(6, 2)) + .corner_radius(8.0) + .fill(ui.visuals().selection.bg_fill.linear_multiply(0.3)); + tag_frame.show(ui, |ui| { + ui.label(egui::RichText::new(tag).small().color(text_color)); + }); + } + }); + } + + ui.horizontal(|ui| { + if !preset.author.is_empty() { + ui.label(egui::RichText::new(format!("by {}", preset.author)) + .small() + .color(text_secondary)); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if !preset.is_factory { + if ui.small_button("Delete").clicked() { + delete_path = Some(preset.path.clone()); + button_clicked = true; + } + } + + let has_track = shared.active_layer_id + .and_then(|lid| shared.layer_to_track_map.get(&lid)) + .is_some(); + if ui.add_enabled(has_track, egui::Button::new("Load")).clicked() { + load_index = Some(idx); + button_clicked = true; + } + }); + }); + } + }); + + // Hover highlight and click-to-select (no ui.interact overlay) + let frame_rect = frame_response.response.rect; + let is_hovered = ui.rect_contains_pointer(frame_rect); + + let fill = if is_selected { + ui.visuals().selection.bg_fill.linear_multiply(0.3) + } else if is_hovered { + ui.visuals().widgets.hovered.bg_fill.linear_multiply(0.3) + } else { + egui::Color32::TRANSPARENT + }; + if fill != egui::Color32::TRANSPARENT { + ui.painter().rect_filled(frame_rect, 4.0, fill); + } + + if is_hovered { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + if is_hovered && !button_clicked && ui.input(|i| i.pointer.any_released()) { + new_selection = if is_selected { None } else { Some(idx) }; + } + }); + + let rect = response.response.rect; + ui.painter().line_segment( + [rect.left_bottom(), rect.right_bottom()], + egui::Stroke::new(0.5, ui.visuals().widgets.noninteractive.bg_stroke.color), + ); + } + + self.selected_index = new_selection; + }); + + // Deferred actions after ScrollArea borrow is released + if let Some(idx) = load_index { + self.load_preset(idx, shared); + } + if let Some(path) = delete_path { + if let Err(e) = std::fs::remove_file(&path) { + eprintln!("Failed to delete preset: {e}"); + } + self.needs_reload = true; + } } fn name(&self) -> &str { - "Preset Browser" + "Instrument Browser" + } +} + +fn capitalize_first(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().to_string() + chars.as_str(), } } diff --git a/src/assets/instruments/synthesizers/lead.json b/src/assets/instruments/synthesizers/lead.json index b6d27ff..9fabea0 100644 --- a/src/assets/instruments/synthesizers/lead.json +++ b/src/assets/instruments/synthesizers/lead.json @@ -1,99 +1,139 @@ { "metadata": { "name": "Bright Lead", - "description": "Piercing lead synth with filter modulation", + "description": "Piercing lead synth with filter modulation (polyphonic)", "author": "Lightningbeam", - "version": 1, + "version": 2, "tags": ["lead", "synth", "solo"] }, "midi_targets": [0], - "output_node": 7, + "output_node": 2, "nodes": [ { "id": 0, "node_type": "MidiInput", "name": "MIDI In", "parameters": {}, - "position": [100.0, 100.0] + "position": [100.0, 150.0] }, { "id": 1, - "node_type": "MidiToCV", - "name": "MIDI→CV", - "parameters": {}, - "position": [400.0, 100.0] + "node_type": "VoiceAllocator", + "name": "Voice Allocator", + "parameters": { + "0": 8.0 + }, + "position": [400.0, 150.0], + "template_graph": { + "metadata": { + "name": "Voice Template", + "description": "Per-voice lead synth patch", + "author": "Lightningbeam", + "version": 1, + "tags": [] + }, + "midi_targets": [0], + "output_node": 7, + "nodes": [ + { + "id": 0, + "node_type": "TemplateInput", + "name": "Template Input", + "parameters": {}, + "position": [-200.0, 0.0] + }, + { + "id": 1, + "node_type": "MidiToCV", + "name": "MIDI→CV", + "parameters": {}, + "position": [100.0, 0.0] + }, + { + "id": 2, + "node_type": "Oscillator", + "name": "Osc", + "parameters": { + "0": 440.0, + "1": 0.6, + "2": 2.0 + }, + "position": [400.0, -100.0] + }, + { + "id": 3, + "node_type": "LFO", + "name": "Filter Mod", + "parameters": { + "0": 5.0, + "1": 0.5, + "2": 0.0, + "3": 0.0 + }, + "position": [400.0, 200.0] + }, + { + "id": 4, + "node_type": "Filter", + "name": "LP Filter", + "parameters": { + "0": 2000.0, + "1": 2.0, + "2": 0.0 + }, + "position": [700.0, -80.0] + }, + { + "id": 5, + "node_type": "ADSR", + "name": "Amp Env", + "parameters": { + "0": 0.01, + "1": 0.1, + "2": 0.6, + "3": 0.2 + }, + "position": [700.0, 200.0] + }, + { + "id": 6, + "node_type": "Gain", + "name": "VCA", + "parameters": { + "0": 1.0 + }, + "position": [1000.0, 50.0] + }, + { + "id": 7, + "node_type": "TemplateOutput", + "name": "Template Output", + "parameters": {}, + "position": [1200.0, 50.0] + } + ], + "connections": [ + { "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 }, + { "from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0 }, + { "from_node": 1, "from_port": 1, "to_node": 5, "to_port": 0 }, + { "from_node": 2, "from_port": 0, "to_node": 4, "to_port": 0 }, + { "from_node": 3, "from_port": 0, "to_node": 4, "to_port": 1 }, + { "from_node": 4, "from_port": 0, "to_node": 6, "to_port": 0 }, + { "from_node": 5, "from_port": 0, "to_node": 6, "to_port": 1 }, + { "from_node": 6, "from_port": 0, "to_node": 7, "to_port": 0 } + ] + } }, { "id": 2, - "node_type": "Oscillator", - "name": "Osc", - "parameters": { - "0": 440.0, - "1": 0.6, - "2": 2.0 - }, - "position": [700.0, -100.0] - }, - { - "id": 3, - "node_type": "LFO", - "name": "Filter Mod", - "parameters": { - "0": 5.0, - "1": 0.5, - "2": 0.0, - "3": 0.0 - }, - "position": [700.0, 200.0] - }, - { - "id": 4, - "node_type": "Filter", - "name": "LP Filter", - "parameters": { - "0": 2000.0, - "1": 2.0, - "2": 0.0 - }, - "position": [1000.0, -80.0] - }, - { - "id": 5, - "node_type": "ADSR", - "name": "Amp Env", - "parameters": { - "0": 0.01, - "1": 0.1, - "2": 0.6, - "3": 0.2 - }, - "position": [1000.0, 240.0] - }, - { - "id": 6, - "node_type": "Gain", - "name": "VCA", - "parameters": { - "0": 1.0 - }, - "position": [1300.0, 150.0] - }, - { - "id": 7, "node_type": "AudioOutput", "name": "Out", "parameters": {}, - "position": [1600.0, 150.0] + "position": [700.0, 150.0] } ], "connections": [ { "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 }, - { "from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0 }, - { "from_node": 1, "from_port": 1, "to_node": 5, "to_port": 0 }, - { "from_node": 2, "from_port": 0, "to_node": 4, "to_port": 0 }, - { "from_node": 3, "from_port": 0, "to_node": 4, "to_port": 1 }, - { "from_node": 4, "from_port": 0, "to_node": 6, "to_port": 0 }, - { "from_node": 5, "from_port": 0, "to_node": 6, "to_port": 1 }, - { "from_node": 6, "from_port": 0, "to_node": 7, "to_port": 0 } + { "from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0 } ] } diff --git a/src/assets/instruments/synthesizers/pad.json b/src/assets/instruments/synthesizers/pad.json index 60083f6..d506724 100644 --- a/src/assets/instruments/synthesizers/pad.json +++ b/src/assets/instruments/synthesizers/pad.json @@ -1,13 +1,13 @@ { "metadata": { "name": "Lush Pad", - "description": "Ambient pad with reverb and chorus", + "description": "Ambient pad with reverb and chorus (polyphonic)", "author": "Lightningbeam", - "version": 1, + "version": 2, "tags": ["pad", "ambient", "synth"] }, "midi_targets": [0], - "output_node": 10, + "output_node": 4, "nodes": [ { "id": 0, @@ -18,79 +18,127 @@ }, { "id": 1, - "node_type": "MidiToCV", - "name": "MIDI→CV", - "parameters": {}, - "position": [400.0, 150.0] + "node_type": "VoiceAllocator", + "name": "Voice Allocator", + "parameters": { + "0": 8.0 + }, + "position": [400.0, 150.0], + "template_graph": { + "metadata": { + "name": "Voice Template", + "description": "Per-voice pad patch", + "author": "Lightningbeam", + "version": 1, + "tags": [] + }, + "midi_targets": [0], + "output_node": 8, + "nodes": [ + { + "id": 0, + "node_type": "TemplateInput", + "name": "Template Input", + "parameters": {}, + "position": [-200.0, 0.0] + }, + { + "id": 1, + "node_type": "MidiToCV", + "name": "MIDI→CV", + "parameters": {}, + "position": [100.0, 0.0] + }, + { + "id": 2, + "node_type": "Oscillator", + "name": "Osc 1", + "parameters": { + "0": 440.0, + "1": 0.4, + "2": 0.0 + }, + "position": [400.0, -100.0] + }, + { + "id": 3, + "node_type": "Oscillator", + "name": "Osc 2", + "parameters": { + "0": 442.0, + "1": 0.4, + "2": 0.0 + }, + "position": [400.0, 200.0] + }, + { + "id": 4, + "node_type": "Mixer", + "name": "Osc Mix", + "parameters": { + "0": 1.0, + "1": 1.0, + "2": 0.0, + "3": 0.0 + }, + "position": [700.0, 50.0] + }, + { + "id": 5, + "node_type": "Filter", + "name": "LP Filter", + "parameters": { + "0": 1500.0, + "1": 0.707, + "2": 0.0 + }, + "position": [900.0, -50.0] + }, + { + "id": 6, + "node_type": "ADSR", + "name": "Amp Env", + "parameters": { + "0": 0.5, + "1": 0.3, + "2": 0.7, + "3": 1.0 + }, + "position": [900.0, 200.0] + }, + { + "id": 7, + "node_type": "Gain", + "name": "VCA", + "parameters": { + "0": 1.0 + }, + "position": [1100.0, 50.0] + }, + { + "id": 8, + "node_type": "TemplateOutput", + "name": "Template Output", + "parameters": {}, + "position": [1300.0, 50.0] + } + ], + "connections": [ + { "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 }, + { "from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0 }, + { "from_node": 1, "from_port": 0, "to_node": 3, "to_port": 0 }, + { "from_node": 1, "from_port": 1, "to_node": 6, "to_port": 0 }, + { "from_node": 2, "from_port": 0, "to_node": 4, "to_port": 0 }, + { "from_node": 3, "from_port": 0, "to_node": 4, "to_port": 1 }, + { "from_node": 4, "from_port": 0, "to_node": 5, "to_port": 0 }, + { "from_node": 5, "from_port": 0, "to_node": 7, "to_port": 0 }, + { "from_node": 6, "from_port": 0, "to_node": 7, "to_port": 1 }, + { "from_node": 7, "from_port": 0, "to_node": 8, "to_port": 0 } + ] + } }, { "id": 2, - "node_type": "Oscillator", - "name": "Osc 1", - "parameters": { - "0": 440.0, - "1": 0.4, - "2": 0.0 - }, - "position": [700.0, -100.0] - }, - { - "id": 3, - "node_type": "Oscillator", - "name": "Osc 2", - "parameters": { - "0": 442.0, - "1": 0.4, - "2": 0.0 - }, - "position": [700.0, 200.0] - }, - { - "id": 4, - "node_type": "Mixer", - "name": "Osc Mix", - "parameters": { - "0": 1.0, - "1": 1.0, - "2": 0.0, - "3": 0.0 - }, - "position": [1000.0, 150.0] - }, - { - "id": 5, - "node_type": "Filter", - "name": "LP Filter", - "parameters": { - "0": 1500.0, - "1": 0.707, - "2": 0.0 - }, - "position": [1300.0, -50.0] - }, - { - "id": 6, - "node_type": "ADSR", - "name": "Amp Env", - "parameters": { - "0": 0.5, - "1": 0.3, - "2": 0.7, - "3": 1.0 - }, - "position": [1300.0, 280.0] - }, - { - "id": 7, - "node_type": "Gain", - "name": "VCA", - "parameters": { - "0": 1.0 - }, - "position": [1600.0, 200.0] - }, - { - "id": 8, "node_type": "Chorus", "name": "Chorus", "parameters": { @@ -98,10 +146,10 @@ "1": 0.6, "2": 0.4 }, - "position": [1900.0, 200.0] + "position": [700.0, 150.0] }, { - "id": 9, + "id": 3, "node_type": "Reverb", "name": "Reverb", "parameters": { @@ -109,28 +157,20 @@ "1": 0.5, "2": 0.5 }, - "position": [2200.0, 200.0] + "position": [1000.0, 150.0] }, { - "id": 10, + "id": 4, "node_type": "AudioOutput", "name": "Out", "parameters": {}, - "position": [2500.0, 200.0] + "position": [1300.0, 150.0] } ], "connections": [ { "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 }, { "from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0 }, - { "from_node": 1, "from_port": 0, "to_node": 3, "to_port": 0 }, - { "from_node": 1, "from_port": 1, "to_node": 6, "to_port": 0 }, - { "from_node": 2, "from_port": 0, "to_node": 4, "to_port": 0 }, - { "from_node": 3, "from_port": 0, "to_node": 4, "to_port": 1 }, - { "from_node": 4, "from_port": 0, "to_node": 5, "to_port": 0 }, - { "from_node": 5, "from_port": 0, "to_node": 7, "to_port": 0 }, - { "from_node": 6, "from_port": 0, "to_node": 7, "to_port": 1 }, - { "from_node": 7, "from_port": 0, "to_node": 8, "to_port": 0 }, - { "from_node": 8, "from_port": 0, "to_node": 9, "to_port": 0 }, - { "from_node": 9, "from_port": 0, "to_node": 10, "to_port": 0 } + { "from_node": 2, "from_port": 0, "to_node": 3, "to_port": 0 }, + { "from_node": 3, "from_port": 0, "to_node": 4, "to_port": 0 } ] } From da147fe6d4775f639cfcf2c5c6c6d4d1abc90a36 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 16 Feb 2026 06:16:05 -0500 Subject: [PATCH 4/4] Stop virtual piano from stealing keyboard focus from input elements --- .../src/panes/virtual_piano.rs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs index e64e8c4..789416b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs @@ -758,13 +758,24 @@ impl PaneRenderer for VirtualPianoPane { return; } - // Request keyboard focus to prevent tool shortcuts from firing - // This sets wants_keyboard_input() to true + // Request keyboard focus to prevent tool shortcuts from firing, + // but yield to text input widgets (node finder search, group rename, etc.) let piano_id = ui.id().with("virtual_piano_keyboard"); - ui.memory_mut(|m| m.request_focus(piano_id)); + let other_has_focus = ui.memory(|m| { + m.focused().map_or(false, |id| id != piano_id) + }); + if !other_has_focus { + ui.memory_mut(|m| m.request_focus(piano_id)); + } - // Handle keyboard input FIRST - self.handle_keyboard_input(ui, shared); + // Handle keyboard input (skip when a text field has focus) + if other_has_focus { + if !self.active_key_presses.is_empty() { + self.release_all_keyboard_notes(shared); + } + } else { + self.handle_keyboard_input(ui, shared); + } // Calculate visible range (needed for both rendering and labels) let (visible_start, visible_end, white_key_width, offset_x) =