diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index a7ebc05..62593cc 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -466,6 +466,66 @@ impl Engine { } } + /// Read audio from pool as mono f32 samples. + /// Handles all storage types: InMemory/Mapped use read_samples(), + /// Compressed falls back to decoding from the file path. + fn read_mono_from_pool(pool: &crate::audio::pool::AudioClipPool, pool_index: usize) -> Option<(Vec, f32)> { + let audio_file = pool.get_file(pool_index)?; + let channels = audio_file.channels as usize; + let frames = audio_file.frames as usize; + let sample_rate = audio_file.sample_rate as f32; + + // Try read_samples first (works for InMemory and Mapped) + let mut mono_samples = vec![0.0f32; frames]; + let read_count = if channels == 1 { + audio_file.read_samples(0, frames, 0, &mut mono_samples) + } else { + let mut channel_buf = vec![0.0f32; frames]; + let mut count = 0; + for ch in 0..channels { + count = audio_file.read_samples(0, frames, ch, &mut channel_buf); + for (i, &s) in channel_buf.iter().enumerate() { + mono_samples[i] += s; + } + } + let scale = 1.0 / channels as f32; + for s in &mut mono_samples { + *s *= scale; + } + count + }; + + if read_count > 0 { + return Some((mono_samples, sample_rate)); + } + + // Compressed storage: decode from file path using sample_loader + let path = audio_file.path.to_string_lossy(); + if !path.starts_with(" 0 { + let actual_frames = data.len() / channels; + let mut mono = vec![0.0f32; actual_frames]; + for frame in 0..actual_frames { + let mut sum = 0.0f32; + for ch in 0..channels { + sum += data[frame * channels + ch]; + } + mono[frame] = sum / channels as f32; + } + return Some((mono, sample_rate)); + } + + eprintln!("[read_mono_from_pool] Failed to read audio from pool_index={}", pool_index); + None + } + /// Handle a command from the UI thread fn handle_command(&mut self, cmd: Command) { match cmd { @@ -1586,6 +1646,38 @@ impl Engine { } } + Command::SamplerLoadFromPool(track_id, node_id, pool_index) => { + use crate::audio::node_graph::nodes::SimpleSamplerNode; + + let sample_result = Self::read_mono_from_pool(&self.audio_pool, pool_index); + + if let Some((mono_samples, sample_rate)) = sample_result { + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + if let Some(sampler_node) = graph_node.node.as_any_mut().downcast_mut::() { + sampler_node.set_sample(mono_samples, sample_rate); + } + } + } + } + } + + Command::SamplerSetRootNote(track_id, node_id, root_note) => { + use crate::audio::node_graph::nodes::SimpleSamplerNode; + + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + if let Some(sampler_node) = graph_node.node.as_any_mut().downcast_mut::() { + sampler_node.set_root_note(root_note); + } + } + } + } + Command::MultiSamplerAddLayer(track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) => { use crate::audio::node_graph::nodes::MultiSamplerNode; @@ -1604,6 +1696,29 @@ impl Engine { } } + Command::MultiSamplerAddLayerFromPool(track_id, node_id, pool_index, key_min, key_max, root_key) => { + use crate::audio::node_graph::nodes::MultiSamplerNode; + use crate::audio::node_graph::nodes::LoopMode; + + let sample_result = Self::read_mono_from_pool(&self.audio_pool, pool_index); + + if let Some((mono_samples, sample_rate)) = sample_result { + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + if let Some(multi_node) = graph_node.node.as_any_mut().downcast_mut::() { + multi_node.add_layer( + mono_samples, sample_rate, + key_min, key_max, root_key, + 0, 127, None, None, LoopMode::OneShot, + ); + } + } + } + } + } + Command::MultiSamplerUpdateLayer(track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) => { use crate::audio::node_graph::nodes::MultiSamplerNode; @@ -3099,11 +3214,26 @@ impl EngineController { let _ = self.command_tx.push(Command::SamplerLoadSample(track_id, node_id, file_path)); } + /// Load a sample from the audio pool into a SimpleSampler node + pub fn sampler_load_from_pool(&mut self, track_id: TrackId, node_id: u32, pool_index: usize) { + let _ = self.command_tx.push(Command::SamplerLoadFromPool(track_id, node_id, pool_index)); + } + + /// Set the root note for a SimpleSampler node + pub fn sampler_set_root_note(&mut self, track_id: TrackId, node_id: u32, root_note: u8) { + let _ = self.command_tx.push(Command::SamplerSetRootNote(track_id, node_id, root_note)); + } + /// Add a sample layer to a MultiSampler node pub fn multi_sampler_add_layer(&mut self, track_id: TrackId, node_id: u32, file_path: String, key_min: u8, key_max: u8, root_key: u8, velocity_min: u8, velocity_max: u8, loop_start: Option, loop_end: Option, loop_mode: crate::audio::node_graph::nodes::LoopMode) { let _ = self.command_tx.push(Command::MultiSamplerAddLayer(track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode)); } + /// Add a sample layer from the audio pool to a MultiSampler node + pub fn multi_sampler_add_layer_from_pool(&mut self, track_id: TrackId, node_id: u32, pool_index: usize, key_min: u8, key_max: u8, root_key: u8) { + let _ = self.command_tx.push(Command::MultiSamplerAddLayerFromPool(track_id, node_id, pool_index, key_min, key_max, root_key)); + } + /// Update a MultiSampler layer's configuration pub fn multi_sampler_update_layer(&mut self, track_id: TrackId, node_id: u32, layer_index: usize, key_min: u8, key_max: u8, root_key: u8, velocity_min: u8, velocity_max: u8, loop_start: Option, loop_end: Option, loop_mode: crate::audio::node_graph::nodes::LoopMode) { let _ = self.command_tx.push(Command::MultiSamplerUpdateLayer(track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode)); diff --git a/daw-backend/src/audio/node_graph/nodes/oscilloscope.rs b/daw-backend/src/audio/node_graph/nodes/oscilloscope.rs index 3c8e80e..ac72a5c 100644 --- a/daw-backend/src/audio/node_graph/nodes/oscilloscope.rs +++ b/daw-backend/src/audio/node_graph/nodes/oscilloscope.rs @@ -101,8 +101,7 @@ impl OscilloscopeNode { let inputs = vec![ NodePort::new("Audio In", SignalType::Audio, 0), - NodePort::new("V/oct", SignalType::CV, 1), - NodePort::new("CV In", SignalType::CV, 2), + NodePort::new("CV In", SignalType::CV, 1), ]; let outputs = vec![ @@ -223,13 +222,24 @@ impl AudioNode for OscilloscopeNode { let output = &mut outputs[0]; let len = input.len().min(output.len()); - // Read V/oct input if available and update trigger period + // Read CV input if available (port 1) — used for both display and V/Oct triggering if inputs.len() > 1 && !inputs[1].is_empty() { - self.voct_value = inputs[1][0]; // Use first sample of V/oct input - let frequency = Self::voct_to_frequency(self.voct_value); - // Calculate period in samples, clamped to reasonable range - let period_samples = (sample_rate as f32 / frequency).max(1.0); - self.trigger_period = period_samples as usize; + let cv_input = inputs[1]; + let cv_len = len.min(cv_input.len()); + + // Check if connected (not NaN sentinel) + if cv_len > 0 && !cv_input[0].is_nan() { + // Update V/Oct trigger period from CV value + self.voct_value = cv_input[0]; + let frequency = Self::voct_to_frequency(self.voct_value); + let period_samples = (sample_rate as f32 / frequency).max(1.0); + self.trigger_period = period_samples as usize; + + // Capture CV samples to buffer + if let Ok(mut cv_buffer) = self.cv_buffer.lock() { + cv_buffer.write(&cv_input[..cv_len]); + } + } } // Update sample counter for V/oct triggering @@ -245,14 +255,6 @@ impl AudioNode for OscilloscopeNode { buffer.write(&input[..len]); } - // Capture CV samples if CV input is connected (input 2) - if inputs.len() > 2 && !inputs[2].is_empty() { - let cv_input = inputs[2]; - if let Ok(mut cv_buffer) = self.cv_buffer.lock() { - cv_buffer.write(&cv_input[..len.min(cv_input.len())]); - } - } - // Update last sample for trigger detection (use left channel, frame 0) if !input.is_empty() { self.last_sample = input[0]; diff --git a/daw-backend/src/audio/node_graph/nodes/simple_sampler.rs b/daw-backend/src/audio/node_graph/nodes/simple_sampler.rs index d958c84..5f8dbec 100644 --- a/daw-backend/src/audio/node_graph/nodes/simple_sampler.rs +++ b/daw-backend/src/audio/node_graph/nodes/simple_sampler.rs @@ -25,6 +25,7 @@ pub struct SimpleSamplerNode { gain: f32, loop_enabled: bool, pitch_shift: f32, // Additional pitch shift in semitones + root_note: u8, // MIDI note for original pitch playback (default 69 = A4) inputs: Vec, outputs: Vec, @@ -61,6 +62,7 @@ impl SimpleSamplerNode { gain: 1.0, loop_enabled: false, pitch_shift: 0.0, + root_note: 69, // A4 — V/Oct 0.0 from MIDI-to-CV inputs, outputs, parameters, @@ -101,13 +103,25 @@ impl SimpleSamplerNode { } /// Convert V/oct CV to playback speed multiplier - /// 0V = 1.0 (original speed), +1V = 2.0 (one octave up), -1V = 0.5 (one octave down) + /// Accounts for root_note: when the incoming MIDI note matches root_note, + /// the sample plays at original speed. V/Oct 0.0 = A4 (MIDI 69) by convention. fn voct_to_speed(&self, voct: f32) -> f32 { - // Add pitch shift parameter - let total_semitones = voct * 12.0 + self.pitch_shift; + // Offset so root_note plays at original speed + let root_offset = (self.root_note as f32 - 69.0) / 12.0; + let total_semitones = (voct - root_offset) * 12.0 + self.pitch_shift; 2.0_f32.powf(total_semitones / 12.0) } + /// Set the root note (MIDI note number for original-pitch playback) + pub fn set_root_note(&mut self, note: u8) { + self.root_note = note.min(127); + } + + /// Get the current root note + pub fn root_note(&self) -> u8 { + self.root_note + } + /// Read sample at playhead with linear interpolation fn read_sample(&self, playhead: f32, sample: &[f32]) -> f32 { if sample.is_empty() { diff --git a/daw-backend/src/audio/node_graph/nodes/slew_limiter.rs b/daw-backend/src/audio/node_graph/nodes/slew_limiter.rs index b9e44a6..d3fb4fd 100644 --- a/daw-backend/src/audio/node_graph/nodes/slew_limiter.rs +++ b/daw-backend/src/audio/node_graph/nodes/slew_limiter.rs @@ -1,4 +1,4 @@ -use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; use crate::audio::midi::MidiEvent; const PARAM_RISE_TIME: u32 = 0; @@ -90,9 +90,8 @@ impl AudioNode for SlewLimiterNode { return; } - let input = inputs[0]; let output = &mut outputs[0]; - let length = input.len().min(output.len()); + let length = output.len(); // Calculate maximum change per sample let sample_duration = 1.0 / sample_rate as f32; @@ -111,7 +110,9 @@ impl AudioNode for SlewLimiterNode { }; for i in 0..length { - let target = input[i]; + // Use cv_input_or_default to handle unconnected inputs (NaN sentinel) + // Default to last_value so output holds steady when unconnected + let target = cv_input_or_default(inputs, 0, i, self.last_value); let difference = target - self.last_value; let max_change = if difference > 0.0 { diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 44bfa31..16ce411 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -177,8 +177,14 @@ pub enum Command { /// Load a sample into a SimpleSampler node (track_id, node_id, file_path) SamplerLoadSample(TrackId, u32, String), + /// Load a sample from the audio pool into a SimpleSampler node (track_id, node_id, pool_index) + SamplerLoadFromPool(TrackId, u32, usize), + /// Set the root note (original pitch) for a SimpleSampler node (track_id, node_id, midi_note) + SamplerSetRootNote(TrackId, u32, u8), /// Add a sample layer to a MultiSampler node (track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) MultiSamplerAddLayer(TrackId, u32, String, u8, u8, u8, u8, u8, Option, Option, LoopMode), + /// Add a sample layer from the audio pool to a MultiSampler node (track_id, node_id, pool_index, key_min, key_max, root_key) + MultiSamplerAddLayerFromPool(TrackId, u32, usize, u8, u8, u8), /// Update a MultiSampler layer's configuration (track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) MultiSamplerUpdateLayer(TrackId, u32, usize, u8, u8, u8, u8, u8, Option, Option, LoopMode), /// Remove a layer from a MultiSampler node (track_id, node_id, layer_index) diff --git a/lightningbeam-ui/egui_node_graph2/src/editor_ui.rs b/lightningbeam-ui/egui_node_graph2/src/editor_ui.rs index 72d436e..6cf8837 100644 --- a/lightningbeam-ui/egui_node_graph2/src/editor_ui.rs +++ b/lightningbeam-ui/egui_node_graph2/src/editor_ui.rs @@ -332,7 +332,9 @@ where ports: &SlotMap, port_locations: &PortLocations, cursor_pos: Pos2, + zoom: f32, ) -> Pos2 { + let snap_distance = DISTANCE_TO_CONNECT * zoom; ports .iter() .find_map(|(port_id, _)| { @@ -352,7 +354,7 @@ where .unwrap() }) .filter(|nearest_hook| { - nearest_hook.distance(cursor_pos) < DISTANCE_TO_CONNECT + nearest_hook.distance(cursor_pos) < snap_distance }) .copied() }) @@ -372,6 +374,7 @@ where &self.graph.inputs, &port_locations, cursor_pos, + self.pan_zoom.zoom, ), ), AnyParameterId::Input(_) => ( @@ -381,6 +384,7 @@ where &self.graph.outputs, &port_locations, cursor_pos, + self.pan_zoom.zoom, ), start_pos, ), 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 c0f793e..1ccaa62 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 @@ -135,20 +135,85 @@ impl NodeTemplate { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct NodeData { pub template: NodeTemplate, + /// Display name of loaded sample (for SimpleSampler/MultiSampler nodes) + #[serde(default)] + pub sample_display_name: Option, + /// Root note (MIDI note number) for original-pitch playback (default 69 = A4) + #[serde(default = "default_root_note")] + pub root_note: u8, } +fn default_root_note() -> u8 { 69 } + /// Cached oscilloscope waveform data for rendering in node body pub struct OscilloscopeCache { pub audio: Vec, pub cv: Vec, } +/// Info about an audio clip available for sampler selection +pub struct SamplerClipInfo { + pub name: String, + pub pool_index: usize, +} + +/// Info about an asset folder available for multi-sampler +pub struct SamplerFolderInfo { + pub folder_id: uuid::Uuid, + pub name: String, + /// Pool indices of audio clips in this folder + pub clip_pool_indices: Vec<(String, usize)>, +} + +/// 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 + SimpleFromPool { node_id: NodeId, backend_node_id: u32, pool_index: usize, name: String }, + /// Open a file dialog to load into a SimpleSampler + SimpleFromFile { node_id: NodeId, backend_node_id: u32 }, + /// Load a single clip from the audio pool as a MultiSampler layer + MultiFromPool { node_id: NodeId, backend_node_id: u32, pool_index: usize, name: String }, + /// Load all clips in a folder as MultiSampler layers + MultiFromFolder { node_id: NodeId, folder_id: uuid::Uuid }, + /// Open a file/folder dialog to load into a MultiSampler + MultiFromFilesystem { node_id: NodeId, backend_node_id: u32 }, +} + /// 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, + /// Audio clips available for sampler selection, populated before draw + pub available_clips: Vec, + /// Asset folders available for multi-sampler, populated before draw + pub available_folders: Vec, + /// Pending sample load request from bottom_ui popup + pub pending_sampler_load: Option, + /// Search text for the sampler clip picker popup + pub sampler_search_text: String, + /// Mapping from frontend NodeId to backend node index, populated before draw + pub node_backend_ids: HashMap, + /// Pending root note changes from bottom_ui (node_id, backend_node_id, new_root_note) + pub pending_root_note_changes: Vec<(NodeId, u32, u8)>, + /// Time scale per oscilloscope node (in milliseconds) + pub oscilloscope_time_scale: HashMap, +} + +impl Default for GraphState { + fn default() -> Self { + Self { + active_node: None, + oscilloscope_data: HashMap::new(), + available_clips: Vec::new(), + available_folders: Vec::new(), + pending_sampler_load: None, + sampler_search_text: String::new(), + node_backend_ids: HashMap::new(), + pending_root_note_changes: Vec::new(), + oscilloscope_time_scale: HashMap::new(), + } + } } /// User response type (empty for now) @@ -333,7 +398,7 @@ impl NodeTemplateTrait for NodeTemplate { } fn user_data(&self, _user_state: &mut Self::UserState) -> Self::NodeData { - NodeData { template: *self } + NodeData { template: *self, sample_display_name: None, root_note: 69 } } fn build_node( @@ -498,6 +563,7 @@ impl NodeTemplateTrait for NodeTemplate { graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio); } NodeTemplate::SimpleSampler => { + graph.add_input_param(node_id, "V/Oct".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOnly, true); graph.add_input_param(node_id, "Gate".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOnly, true); graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio); } @@ -781,6 +847,14 @@ impl WidgetValueTrait for ValueType { } } +const NOTE_NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; + +fn midi_note_name(note: u8) -> String { + let octave = (note as i32 / 12) - 1; + let name = NOTE_NAMES[note as usize % 12]; + format!("{}{}", name, octave) +} + // Implement NodeDataTrait for custom node UI (optional) impl NodeDataTrait for NodeData { type Response = UserResponse; @@ -798,7 +872,123 @@ impl NodeDataTrait for NodeData { where Self::Response: UserResponseTrait, { - if self.template == NodeTemplate::Oscilloscope { + if self.template == NodeTemplate::SimpleSampler || self.template == NodeTemplate::MultiSampler { + let is_multi = self.template == NodeTemplate::MultiSampler; + let backend_node_id = user_state.node_backend_ids.get(&node_id).copied().unwrap_or(0); + let default_text = if is_multi { "Select samples..." } else { "Select sample..." }; + let button_text = self.sample_display_name.as_deref().unwrap_or(default_text); + + let button = ui.button(button_text); + if button.clicked() { + user_state.sampler_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) + .show(|ui| { + ui.set_min_width(200.0); + ui.text_edit_singleline(&mut user_state.sampler_search_text); + ui.separator(); + let search = user_state.sampler_search_text.to_lowercase(); + + // Folders section (multi-sampler only) + if is_multi && !user_state.available_folders.is_empty() { + ui.label(egui::RichText::new("Folders").small().weak()); + for folder in &user_state.available_folders { + if !search.is_empty() && !folder.name.to_lowercase().contains(&search) { + continue; + } + let label = format!("📁 {} ({} clips)", folder.name, folder.clip_pool_indices.len()); + if ui.selectable_label(false, label).clicked() { + user_state.pending_sampler_load = Some(PendingSamplerLoad::MultiFromFolder { + node_id, + folder_id: folder.folder_id, + }); + close_popup = true; + } + } + ui.separator(); + } + + // Audio clips list + if is_multi { + ui.label(egui::RichText::new("Audio Clips").small().weak()); + } + egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { + for clip in &user_state.available_clips { + if !search.is_empty() && !clip.name.to_lowercase().contains(&search) { + continue; + } + if ui.selectable_label(false, &clip.name).clicked() { + if is_multi { + user_state.pending_sampler_load = Some(PendingSamplerLoad::MultiFromPool { + node_id, + backend_node_id, + pool_index: clip.pool_index, + name: clip.name.clone(), + }); + } else { + user_state.pending_sampler_load = Some(PendingSamplerLoad::SimpleFromPool { + node_id, + backend_node_id, + pool_index: clip.pool_index, + name: clip.name.clone(), + }); + } + close_popup = true; + } + } + }); + ui.separator(); + if ui.button("Open...").clicked() { + if is_multi { + user_state.pending_sampler_load = Some(PendingSamplerLoad::MultiFromFilesystem { + node_id, + backend_node_id, + }); + } else { + user_state.pending_sampler_load = Some(PendingSamplerLoad::SimpleFromFile { + node_id, + backend_node_id, + }); + } + close_popup = true; + } + }); + + if close_popup { + egui::Popup::close_id(ui.ctx(), popup_id); + } + + // Root note selector + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Root:").weak()); + let note_name = midi_note_name(self.root_note); + let root_btn = ui.button(¬e_name); + let root_popup_id = egui::Popup::default_response_id(&root_btn); + let mut close_root = false; + egui::Popup::from_toggle_button_response(&root_btn) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| { + ui.set_min_width(120.0); + egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { + // Show notes from C1 (24) to C7 (96) + for note in (24..=96).rev() { + let name = midi_note_name(note); + if ui.selectable_label(note == self.root_note, &name).clicked() { + user_state.pending_root_note_changes.push((node_id, backend_node_id, note)); + close_root = true; + } + } + }); + }); + if close_root { + egui::Popup::close_id(ui.ctx(), root_popup_id); + } + }); + } else 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); @@ -834,6 +1024,15 @@ impl NodeDataTrait for NodeData { painter.add(egui::Shape::line(points, egui::Stroke::new(1.5, egui::Color32::from_rgb(0xFF, 0x98, 0x00)))); } } + + // Time window slider + let time_ms = user_state.oscilloscope_time_scale.entry(node_id).or_insert(100.0); + ui.horizontal(|ui| { + ui.spacing_mut().slider_width = 140.0; + ui.add(egui::Slider::new(time_ms, 10.0..=1000.0) + .suffix(" ms") + .logarithmic(true)); + }); } 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 e865e7b..adade75 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -560,6 +560,100 @@ impl NodeGraphPane { } } + fn handle_pending_sampler_load( + &mut self, + load: graph_data::PendingSamplerLoad, + shared: &mut crate::panes::SharedPaneState, + ) { + let backend_track_id = match self.backend_track_id { + Some(id) => id, + None => return, + }; + let controller_arc = match &shared.audio_controller { + Some(c) => std::sync::Arc::clone(c), + None => return, + }; + + match load { + graph_data::PendingSamplerLoad::SimpleFromPool { node_id, backend_node_id, pool_index, name } => { + let mut controller = controller_arc.lock().unwrap(); + controller.sampler_load_from_pool(backend_track_id, backend_node_id, pool_index); + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.sample_display_name = Some(name); + } + } + graph_data::PendingSamplerLoad::SimpleFromFile { node_id, backend_node_id } => { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Audio", &["wav", "flac", "mp3", "ogg", "aiff"]) + .pick_file() + { + let path_str = path.to_string_lossy().to_string(); + let file_name = path.file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "Sample".to_string()); + let mut controller = controller_arc.lock().unwrap(); + controller.sampler_load_sample(backend_track_id, backend_node_id, path_str); + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.sample_display_name = Some(file_name); + } + } + } + graph_data::PendingSamplerLoad::MultiFromPool { node_id, backend_node_id, pool_index, name } => { + let mut controller = controller_arc.lock().unwrap(); + // Add as a single layer spanning full key range, root_key = 60 (C4) + controller.multi_sampler_add_layer_from_pool( + backend_track_id, backend_node_id, pool_index, + 0, 127, 60, + ); + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.sample_display_name = Some(name); + } + } + graph_data::PendingSamplerLoad::MultiFromFolder { node_id, folder_id } => { + // Find folder clips from available_folders + let folder_clips: Vec<(String, usize)> = self.user_state.available_folders.iter() + .find(|f| f.folder_id == folder_id) + .map(|f| f.clip_pool_indices.clone()) + .unwrap_or_default(); + + if !folder_clips.is_empty() { + // TODO: Add MultiSamplerLoadFromPool command to avoid disk re-reads. + // For now, folder loading is a placeholder — the UI is wired up but + // loading multi-sampler layers from pool requires a new backend command. + let folder_name = self.user_state.available_folders.iter() + .find(|f| f.folder_id == folder_id) + .map(|f| f.name.clone()) + .unwrap_or_else(|| "Folder".to_string()); + eprintln!("MultiSampler folder load not yet implemented for folder: {}", folder_name); + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.sample_display_name = Some(format!("📁 {}", folder_name)); + } + } + } + graph_data::PendingSamplerLoad::MultiFromFilesystem { node_id, backend_node_id } => { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Audio", &["wav", "flac", "mp3", "ogg", "aiff"]) + .pick_file() + { + let path_str = path.to_string_lossy().to_string(); + let file_name = path.file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "Sample".to_string()); + let mut controller = controller_arc.lock().unwrap(); + // Add as layer spanning full key range + controller.multi_sampler_add_layer( + backend_track_id, backend_node_id, path_str, + 0, 127, 60, 0, 127, None, None, + daw_backend::audio::node_graph::nodes::LoopMode::OneShot, + ); + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.sample_display_name = Some(file_name); + } + } + } + } + } + fn check_parameter_changes(&mut self, shared: &mut crate::panes::SharedPaneState) { // Check all input parameters for value changes let mut _checked_count = 0; @@ -1554,7 +1648,7 @@ impl NodeGraphPane { label: group.name.clone(), inputs: vec![], outputs: vec![], - user_data: NodeData { template: NodeTemplate::Group }, + user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 }, }); // Add dynamic input ports based on boundary inputs @@ -1626,7 +1720,7 @@ impl NodeGraphPane { label: "Group Input".to_string(), inputs: vec![], outputs: vec![], - user_data: NodeData { template: NodeTemplate::Group }, + user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 }, }); for bc in &scope_group.boundary_inputs { @@ -1673,7 +1767,7 @@ impl NodeGraphPane { label: "Group Output".to_string(), inputs: vec![], outputs: vec![], - user_data: NodeData { template: NodeTemplate::Group }, + user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 }, }); for bc in &scope_group.boundary_outputs { @@ -1866,7 +1960,7 @@ impl NodeGraphPane { label: label.to_string(), inputs: vec![], outputs: vec![], - user_data: NodeData { template: node_template }, + user_data: NodeData { template: node_template, sample_display_name: None, root_note: 69 }, }); node_template.build_node(&mut self.state.graph, &mut self.user_state, frontend_id); @@ -2042,10 +2136,14 @@ impl crate::panes::PaneRenderer for NodeGraphPane { let mut controller = audio_controller.lock().unwrap(); for (node_id, backend_node_id) in oscilloscope_nodes { + // Calculate sample count from per-node time scale (default 100ms) + let time_ms = self.user_state.oscilloscope_time_scale + .get(&node_id).copied().unwrap_or(100.0); + let sample_count = ((time_ms / 1000.0) * 48000.0) as usize; let result = if let Some(va_id) = va_backend_id { - controller.query_voice_oscilloscope_data(backend_track_id, va_id, backend_node_id, 4800) + controller.query_voice_oscilloscope_data(backend_track_id, va_id, backend_node_id, sample_count) } else { - controller.query_oscilloscope_data(backend_track_id, backend_node_id, 4800) + controller.query_oscilloscope_data(backend_track_id, backend_node_id, sample_count) }; if let Ok(data) = result { self.user_state.oscilloscope_data.insert(node_id, graph_data::OscilloscopeCache { @@ -2172,6 +2270,55 @@ impl crate::panes::PaneRenderer for NodeGraphPane { let zoom_before = self.state.pan_zoom.zoom; let pan_before = self.state.pan_zoom.pan; + // Populate sampler clip list and node backend ID map for bottom_ui() + { + use lightningbeam_core::clip::AudioClipType; + + let doc = shared.action_executor.document(); + + // Available audio clips + self.user_state.available_clips = doc.audio_clips.values() + .filter_map(|clip| match &clip.clip_type { + AudioClipType::Sampled { audio_pool_index } => Some(graph_data::SamplerClipInfo { + name: clip.name.clone(), + pool_index: *audio_pool_index, + }), + _ => None, + }) + .collect(); + self.user_state.available_clips.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + + // Available folders (with their contained audio clips) + self.user_state.available_folders = doc.audio_folders.folders.values() + .map(|folder| { + let clips_in_folder: Vec<(String, usize)> = doc.audio_clips.values() + .filter(|clip| clip.folder_id == Some(folder.id)) + .filter_map(|clip| match &clip.clip_type { + AudioClipType::Sampled { audio_pool_index } => Some((clip.name.clone(), *audio_pool_index)), + _ => None, + }) + .collect(); + graph_data::SamplerFolderInfo { + folder_id: folder.id, + name: folder.name.clone(), + clip_pool_indices: clips_in_folder, + } + }) + .filter(|f| !f.clip_pool_indices.is_empty()) + .collect(); + self.user_state.available_folders.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + + // Node backend ID map + self.user_state.node_backend_ids = self.node_id_map.iter() + .map(|(&node_id, backend_id)| { + let id = match backend_id { + BackendNodeId::Audio(idx) => idx.index() as u32, + }; + (node_id, id) + }) + .collect(); + } + // Draw dot grid background with pan/zoom let pan_zoom = &self.state.pan_zoom; Self::draw_dot_grid_background(ui, graph_rect, bg_color, grid_color, pan_zoom); @@ -2204,6 +2351,27 @@ impl crate::panes::PaneRenderer for NodeGraphPane { self.last_node_rects = graph_response.node_rects.clone(); self.handle_graph_response(graph_response, shared, graph_rect); + // Handle pending sampler load requests from bottom_ui() + if let Some(load) = self.user_state.pending_sampler_load.take() { + self.handle_pending_sampler_load(load, shared); + } + + // Handle pending root note changes + if !self.user_state.pending_root_note_changes.is_empty() { + let changes: Vec<_> = self.user_state.pending_root_note_changes.drain(..).collect(); + if let Some(backend_track_id) = self.track_id.and_then(|tid| shared.layer_to_track_map.get(&tid).copied()) { + if let Some(controller_arc) = &shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + for (node_id, backend_node_id, root_note) in changes { + controller.sampler_set_root_note(backend_track_id, backend_node_id, root_note); + if let Some(node) = self.state.graph.nodes.get_mut(node_id) { + node.user_data.root_note = root_note; + } + } + } + } + } + // 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());