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 b7ee6a5..9208eb3 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 @@ -224,6 +224,21 @@ pub enum PendingScriptSampleLoad { FromFile { node_id: NodeId, backend_node_id: u32, slot_index: usize }, } +/// Info about an available NAM model for amp sim selection +pub struct NamModelInfo { + pub name: String, + pub path: String, + pub is_bundled: bool, +} + +/// Pending AmpSim model load request from bottom_ui(), handled by the node graph pane +pub enum PendingAmpSimLoad { + /// Load a known model by path (from bundled list or previously loaded) + FromPath { node_id: NodeId, backend_node_id: u32, path: String, name: String }, + /// Open file dialog to browse for a .nam file + FromFile { node_id: NodeId, backend_node_id: u32 }, +} + /// Pending sampler load request from bottom_ui(), handled by the node graph pane pub enum PendingSamplerLoad { /// Load a single clip from the audio pool into a SimpleSampler @@ -277,8 +292,12 @@ pub struct GraphState { pub pending_draw_param_changes: Vec<(NodeId, u32, f32)>, /// Active sample import dialog (folder import with heuristic mapping) pub sample_import_dialog: Option, - /// Pending AmpSim model load (node_id, backend_node_id) — triggers file dialog for .nam - pub pending_amp_sim_load: Option<(NodeId, u32)>, + /// Pending AmpSim model load — triggers file dialog or direct load + pub pending_amp_sim_load: Option, + /// Available NAM models for amp sim selection, populated before draw + pub available_nam_models: Vec, + /// Search text for the NAM model picker popup + pub nam_search_text: String, } impl Default for GraphState { @@ -303,6 +322,8 @@ impl Default for GraphState { pending_draw_param_changes: Vec::new(), sample_import_dialog: None, pending_amp_sim_load: None, + available_nam_models: Vec::new(), + nam_search_text: String::new(), } } } @@ -1400,9 +1421,77 @@ impl NodeDataTrait for NodeData { } } else if self.template == NodeTemplate::AmpSim { let backend_node_id = user_state.node_backend_ids.get(&node_id).copied().unwrap_or(0); - let button_text = self.nam_model_name.as_deref().unwrap_or("Load Model..."); - if ui.button(button_text).clicked() { - user_state.pending_amp_sim_load = Some((node_id, backend_node_id)); + let button_text = self.nam_model_name.as_deref().unwrap_or("Select Model..."); + + let button = ui.button(button_text); + if button.clicked() { + user_state.nam_search_text.clear(); + } + let popup_id = egui::Popup::default_response_id(&button); + + let mut close_popup = false; + egui::Popup::from_toggle_button_response(&button) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .width(200.0) + .show(|ui| { + let search_width = ui.available_width(); + ui.add_sized([search_width, 0.0], egui::TextEdit::singleline(&mut user_state.nam_search_text).hint_text("Search...")); + ui.separator(); + let search = user_state.nam_search_text.to_lowercase(); + + let bundled: Vec<&NamModelInfo> = user_state.available_nam_models.iter() + .filter(|m| m.is_bundled && (search.is_empty() || m.name.to_lowercase().contains(&search))) + .collect(); + let user_models: Vec<&NamModelInfo> = user_state.available_nam_models.iter() + .filter(|m| !m.is_bundled && (search.is_empty() || m.name.to_lowercase().contains(&search))) + .collect(); + + if !bundled.is_empty() { + ui.label(egui::RichText::new("Bundled").small().weak()); + let items = bundled.iter().map(|m| { + let selected = self.nam_model_name.as_deref() == Some(m.name.as_str()); + (selected, m.name.as_str()) + }); + if let Some(idx) = widgets::scrollable_list(ui, 200.0, items) { + let model = bundled[idx]; + user_state.pending_amp_sim_load = Some(PendingAmpSimLoad::FromPath { + node_id, backend_node_id, + path: model.path.clone(), + name: model.name.clone(), + }); + close_popup = true; + } + } + + if !user_models.is_empty() { + ui.separator(); + ui.label(egui::RichText::new("User").small().weak()); + let items = user_models.iter().map(|m| { + let selected = self.nam_model_name.as_deref() == Some(m.name.as_str()); + (selected, m.name.as_str()) + }); + if let Some(idx) = widgets::scrollable_list(ui, 200.0, items) { + let model = user_models[idx]; + user_state.pending_amp_sim_load = Some(PendingAmpSimLoad::FromPath { + node_id, backend_node_id, + path: model.path.clone(), + name: model.name.clone(), + }); + close_popup = true; + } + } + + ui.separator(); + if ui.button("Open...").clicked() { + user_state.pending_amp_sim_load = Some(PendingAmpSimLoad::FromFile { + node_id, backend_node_id, + }); + close_popup = true; + } + }); + + if close_popup { + egui::Popup::close_id(ui.ctx(), popup_id); } } else { ui.label(""); 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 ab03c73..590fbb1 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -8,7 +8,7 @@ pub mod backend; pub mod graph_data; use backend::{BackendNodeId, GraphBackend}; -use graph_data::{AllNodeTemplates, SubgraphNodeTemplates, VoiceAllocatorNodeTemplates, DataType, GraphState, NodeData, NodeTemplate, ValueType}; +use graph_data::{AllNodeTemplates, SubgraphNodeTemplates, VoiceAllocatorNodeTemplates, DataType, GraphState, NamModelInfo, NodeData, NodeTemplate, PendingAmpSimLoad, ValueType}; use super::NodePath; use eframe::egui; use egui_node_graph2::*; @@ -403,6 +403,20 @@ impl NodeGraphPane { ); self.node_id_map.insert(node_id, backend_id); self.backend_to_frontend_map.insert(backend_id, node_id); + + // Auto-load default NAM model for new AmpSim nodes + if node_type == "AmpSim" { + if let Some(model) = self.user_state.available_nam_models.iter().find(|m| m.is_bundled) { + controller.amp_sim_load_model( + backend_track_id, + backend_node.id, + model.path.clone(), + ); + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.nam_model_name = Some(model.name.clone()); + } + } + } } } } @@ -665,6 +679,20 @@ impl NodeGraphPane { ); self.node_id_map.insert(frontend_id, backend_id); self.backend_to_frontend_map.insert(backend_id, frontend_id); + + // Auto-load default NAM model for new AmpSim nodes + if node_type == "AmpSim" { + if let Some(model) = self.user_state.available_nam_models.iter().find(|m| m.is_bundled) { + controller.amp_sim_load_model( + backend_track_id, + backend_node.id, + model.path.clone(), + ); + if let Some(node) = self.state.graph.nodes.get_mut(frontend_id) { + node.user_data.nam_model_name = Some(model.name.clone()); + } + } + } } } } @@ -2469,6 +2497,46 @@ impl crate::panes::PaneRenderer for NodeGraphPane { .collect(); self.user_state.available_scripts.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase())); + // Bundled NAM models — discover once and cache + if self.user_state.available_nam_models.is_empty() { + let bundled_dirs = [ + std::env::current_exe().ok() + .and_then(|p| p.parent().map(|d| d.join("models"))) + .unwrap_or_default(), + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../vendor/NeuralAudio/Utils/Models"), + ]; + for dir in &bundled_dirs { + if let Ok(canon) = dir.canonicalize() { + if canon.is_dir() { + for entry in std::fs::read_dir(&canon).into_iter().flatten().flatten() { + let path = entry.path(); + if path.extension().map_or(false, |e| e == "nam") { + let stem = path.file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + // Skip LSTM variants (performance alternates, not separate amps) + if stem.ends_with("-LSTM") { + continue; + } + // Clean up display name: remove "-WaveNet" suffix + let name = stem.strip_suffix("-WaveNet") + .unwrap_or(&stem) + .to_string(); + self.user_state.available_nam_models.push(NamModelInfo { + name, + path: path.to_string_lossy().to_string(), + is_bundled: true, + }); + } + } + break; // use first directory found + } + } + } + self.user_state.available_nam_models.sort_by(|a, b| a.name.cmp(&b.name)); + } + // Node backend ID map self.user_state.node_backend_ids = self.node_id_map.iter() .map(|(&node_id, backend_id)| { @@ -2518,25 +2586,45 @@ impl crate::panes::PaneRenderer for NodeGraphPane { } // Handle pending AmpSim model load from bottom_ui() - if let Some((node_id, backend_node_id)) = self.user_state.pending_amp_sim_load.take() { + if let Some(load) = self.user_state.pending_amp_sim_load.take() { if let Some(backend_track_id) = self.backend_track_id { - if let Some(path) = rfd::FileDialog::new() - .add_filter("NAM Model", &["nam"]) - .pick_file() - { - let model_name = path.file_stem() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "Model".to_string()); - if let Some(controller_arc) = &shared.audio_controller { - let mut controller = controller_arc.lock().unwrap(); - controller.amp_sim_load_model( - backend_track_id, - backend_node_id, - path.to_string_lossy().to_string(), - ); - } - if let Some(node) = self.state.graph.nodes.get_mut(node_id) { - node.user_data.nam_model_name = Some(model_name); + if let Some(controller_arc) = &shared.audio_controller { + match load { + PendingAmpSimLoad::FromPath { node_id, backend_node_id, path, name } => { + controller_arc.lock().unwrap().amp_sim_load_model( + backend_track_id, backend_node_id, path, + ); + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.nam_model_name = Some(name); + } + } + PendingAmpSimLoad::FromFile { node_id, backend_node_id } => { + if let Some(path) = rfd::FileDialog::new() + .add_filter("NAM Model", &["nam"]) + .pick_file() + { + let model_name = path.file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "Model".to_string()); + controller_arc.lock().unwrap().amp_sim_load_model( + backend_track_id, + backend_node_id, + path.to_string_lossy().to_string(), + ); + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.nam_model_name = Some(model_name.clone()); + } + // Add user-loaded model to the available list if not already present + let path_str = path.to_string_lossy().to_string(); + if !self.user_state.available_nam_models.iter().any(|m| m.path == path_str) { + self.user_state.available_nam_models.push(NamModelInfo { + name: model_name, + path: path_str, + is_bundled: false, + }); + } + } + } } } }