Work on sampler nodes, fix slew limiter

This commit is contained in:
Skyler Lehmkuhl 2026-02-16 18:45:11 -05:00
parent 93a29192fd
commit 2c0d53fb84
8 changed files with 557 additions and 33 deletions

View File

@ -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>, 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("<embedded") {
if let Ok(sample_data) = crate::audio::sample_loader::load_audio_file(&*path) {
return Some((sample_data.samples, sample_data.sample_rate as f32));
}
}
// Last resort: try interleaved data() and mix down
let data = audio_file.data();
if !data.is_empty() && channels > 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 /// Handle a command from the UI thread
fn handle_command(&mut self, cmd: Command) { fn handle_command(&mut self, cmd: Command) {
match cmd { 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::<SimpleSamplerNode>() {
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::<SimpleSamplerNode>() {
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) => { 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; 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::<MultiSamplerNode>() {
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) => { 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; 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)); 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 /// 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<usize>, loop_end: Option<usize>, loop_mode: crate::audio::node_graph::nodes::LoopMode) { 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<usize>, loop_end: Option<usize>, 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)); 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 /// 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<usize>, loop_end: Option<usize>, loop_mode: crate::audio::node_graph::nodes::LoopMode) { 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<usize>, loop_end: Option<usize>, 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)); 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));

View File

@ -101,8 +101,7 @@ impl OscilloscopeNode {
let inputs = vec![ let inputs = vec![
NodePort::new("Audio In", SignalType::Audio, 0), NodePort::new("Audio In", SignalType::Audio, 0),
NodePort::new("V/oct", SignalType::CV, 1), NodePort::new("CV In", SignalType::CV, 1),
NodePort::new("CV In", SignalType::CV, 2),
]; ];
let outputs = vec![ let outputs = vec![
@ -223,13 +222,24 @@ impl AudioNode for OscilloscopeNode {
let output = &mut outputs[0]; let output = &mut outputs[0];
let len = input.len().min(output.len()); 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() { if inputs.len() > 1 && !inputs[1].is_empty() {
self.voct_value = inputs[1][0]; // Use first sample of V/oct input let cv_input = inputs[1];
let frequency = Self::voct_to_frequency(self.voct_value); let cv_len = len.min(cv_input.len());
// Calculate period in samples, clamped to reasonable range
let period_samples = (sample_rate as f32 / frequency).max(1.0); // Check if connected (not NaN sentinel)
self.trigger_period = period_samples as usize; 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 // Update sample counter for V/oct triggering
@ -245,14 +255,6 @@ impl AudioNode for OscilloscopeNode {
buffer.write(&input[..len]); 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) // Update last sample for trigger detection (use left channel, frame 0)
if !input.is_empty() { if !input.is_empty() {
self.last_sample = input[0]; self.last_sample = input[0];

View File

@ -25,6 +25,7 @@ pub struct SimpleSamplerNode {
gain: f32, gain: f32,
loop_enabled: bool, loop_enabled: bool,
pitch_shift: f32, // Additional pitch shift in semitones pitch_shift: f32, // Additional pitch shift in semitones
root_note: u8, // MIDI note for original pitch playback (default 69 = A4)
inputs: Vec<NodePort>, inputs: Vec<NodePort>,
outputs: Vec<NodePort>, outputs: Vec<NodePort>,
@ -61,6 +62,7 @@ impl SimpleSamplerNode {
gain: 1.0, gain: 1.0,
loop_enabled: false, loop_enabled: false,
pitch_shift: 0.0, pitch_shift: 0.0,
root_note: 69, // A4 — V/Oct 0.0 from MIDI-to-CV
inputs, inputs,
outputs, outputs,
parameters, parameters,
@ -101,13 +103,25 @@ impl SimpleSamplerNode {
} }
/// Convert V/oct CV to playback speed multiplier /// 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 { fn voct_to_speed(&self, voct: f32) -> f32 {
// Add pitch shift parameter // Offset so root_note plays at original speed
let total_semitones = voct * 12.0 + self.pitch_shift; 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) 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 /// Read sample at playhead with linear interpolation
fn read_sample(&self, playhead: f32, sample: &[f32]) -> f32 { fn read_sample(&self, playhead: f32, sample: &[f32]) -> f32 {
if sample.is_empty() { if sample.is_empty() {

View File

@ -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; use crate::audio::midi::MidiEvent;
const PARAM_RISE_TIME: u32 = 0; const PARAM_RISE_TIME: u32 = 0;
@ -90,9 +90,8 @@ impl AudioNode for SlewLimiterNode {
return; return;
} }
let input = inputs[0];
let output = &mut outputs[0]; let output = &mut outputs[0];
let length = input.len().min(output.len()); let length = output.len();
// Calculate maximum change per sample // Calculate maximum change per sample
let sample_duration = 1.0 / sample_rate as f32; let sample_duration = 1.0 / sample_rate as f32;
@ -111,7 +110,9 @@ impl AudioNode for SlewLimiterNode {
}; };
for i in 0..length { 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 difference = target - self.last_value;
let max_change = if difference > 0.0 { let max_change = if difference > 0.0 {

View File

@ -177,8 +177,14 @@ pub enum Command {
/// Load a sample into a SimpleSampler node (track_id, node_id, file_path) /// Load a sample into a SimpleSampler node (track_id, node_id, file_path)
SamplerLoadSample(TrackId, u32, String), 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) /// 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<usize>, Option<usize>, LoopMode), MultiSamplerAddLayer(TrackId, u32, String, u8, u8, u8, u8, u8, Option<usize>, Option<usize>, 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) /// 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<usize>, Option<usize>, LoopMode), MultiSamplerUpdateLayer(TrackId, u32, usize, u8, u8, u8, u8, u8, Option<usize>, Option<usize>, LoopMode),
/// Remove a layer from a MultiSampler node (track_id, node_id, layer_index) /// Remove a layer from a MultiSampler node (track_id, node_id, layer_index)

View File

@ -332,7 +332,9 @@ where
ports: &SlotMap<Key, Value>, ports: &SlotMap<Key, Value>,
port_locations: &PortLocations, port_locations: &PortLocations,
cursor_pos: Pos2, cursor_pos: Pos2,
zoom: f32,
) -> Pos2 { ) -> Pos2 {
let snap_distance = DISTANCE_TO_CONNECT * zoom;
ports ports
.iter() .iter()
.find_map(|(port_id, _)| { .find_map(|(port_id, _)| {
@ -352,7 +354,7 @@ where
.unwrap() .unwrap()
}) })
.filter(|nearest_hook| { .filter(|nearest_hook| {
nearest_hook.distance(cursor_pos) < DISTANCE_TO_CONNECT nearest_hook.distance(cursor_pos) < snap_distance
}) })
.copied() .copied()
}) })
@ -372,6 +374,7 @@ where
&self.graph.inputs, &self.graph.inputs,
&port_locations, &port_locations,
cursor_pos, cursor_pos,
self.pan_zoom.zoom,
), ),
), ),
AnyParameterId::Input(_) => ( AnyParameterId::Input(_) => (
@ -381,6 +384,7 @@ where
&self.graph.outputs, &self.graph.outputs,
&port_locations, &port_locations,
cursor_pos, cursor_pos,
self.pan_zoom.zoom,
), ),
start_pos, start_pos,
), ),

View File

@ -135,20 +135,85 @@ impl NodeTemplate {
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NodeData { pub struct NodeData {
pub template: NodeTemplate, pub template: NodeTemplate,
/// Display name of loaded sample (for SimpleSampler/MultiSampler nodes)
#[serde(default)]
pub sample_display_name: Option<String>,
/// 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 /// Cached oscilloscope waveform data for rendering in node body
pub struct OscilloscopeCache { pub struct OscilloscopeCache {
pub audio: Vec<f32>, pub audio: Vec<f32>,
pub cv: Vec<f32>, pub cv: Vec<f32>,
} }
/// 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. /// Custom graph state - can track selected nodes, etc.
#[derive(Default)]
pub struct GraphState { pub struct GraphState {
pub active_node: Option<NodeId>, pub active_node: Option<NodeId>,
/// Oscilloscope data cached per node, populated before draw_graph_editor() /// Oscilloscope data cached per node, populated before draw_graph_editor()
pub oscilloscope_data: HashMap<NodeId, OscilloscopeCache>, pub oscilloscope_data: HashMap<NodeId, OscilloscopeCache>,
/// Audio clips available for sampler selection, populated before draw
pub available_clips: Vec<SamplerClipInfo>,
/// Asset folders available for multi-sampler, populated before draw
pub available_folders: Vec<SamplerFolderInfo>,
/// Pending sample load request from bottom_ui popup
pub pending_sampler_load: Option<PendingSamplerLoad>,
/// 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<NodeId, u32>,
/// Pending root note changes from bottom_ui (node_id, backend_node_id, new_root_note)
pub pending_root_note_changes: Vec<(NodeId, u32, u8)>,
/// Time scale per oscilloscope node (in milliseconds)
pub oscilloscope_time_scale: HashMap<NodeId, f32>,
}
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) /// 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 { 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( fn build_node(
@ -498,6 +563,7 @@ impl NodeTemplateTrait for NodeTemplate {
graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio); graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio);
} }
NodeTemplate::SimpleSampler => { 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_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); 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) // Implement NodeDataTrait for custom node UI (optional)
impl NodeDataTrait for NodeData { impl NodeDataTrait for NodeData {
type Response = UserResponse; type Response = UserResponse;
@ -798,7 +872,123 @@ impl NodeDataTrait for NodeData {
where where
Self::Response: UserResponseTrait, 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(&note_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 size = egui::vec2(200.0, 80.0);
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
let painter = ui.painter_at(rect); 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)))); 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 { } else {
ui.label(""); ui.label("");
} }

View File

@ -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) { fn check_parameter_changes(&mut self, shared: &mut crate::panes::SharedPaneState) {
// Check all input parameters for value changes // Check all input parameters for value changes
let mut _checked_count = 0; let mut _checked_count = 0;
@ -1554,7 +1648,7 @@ impl NodeGraphPane {
label: group.name.clone(), label: group.name.clone(),
inputs: vec![], inputs: vec![],
outputs: 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 // Add dynamic input ports based on boundary inputs
@ -1626,7 +1720,7 @@ impl NodeGraphPane {
label: "Group Input".to_string(), label: "Group Input".to_string(),
inputs: vec![], inputs: vec![],
outputs: 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 { for bc in &scope_group.boundary_inputs {
@ -1673,7 +1767,7 @@ impl NodeGraphPane {
label: "Group Output".to_string(), label: "Group Output".to_string(),
inputs: vec![], inputs: vec![],
outputs: 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 { for bc in &scope_group.boundary_outputs {
@ -1866,7 +1960,7 @@ impl NodeGraphPane {
label: label.to_string(), label: label.to_string(),
inputs: vec![], inputs: vec![],
outputs: 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); 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(); let mut controller = audio_controller.lock().unwrap();
for (node_id, backend_node_id) in oscilloscope_nodes { 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 { 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 { } 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 { if let Ok(data) = result {
self.user_state.oscilloscope_data.insert(node_id, graph_data::OscilloscopeCache { 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 zoom_before = self.state.pan_zoom.zoom;
let pan_before = self.state.pan_zoom.pan; 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 // Draw dot grid background with pan/zoom
let pan_zoom = &self.state.pan_zoom; let pan_zoom = &self.state.pan_zoom;
Self::draw_dot_grid_background(ui, graph_rect, bg_color, grid_color, 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.last_node_rects = graph_response.node_rects.clone();
self.handle_graph_response(graph_response, shared, graph_rect); 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 // 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()); let secondary_clicked = ui.input(|i| i.pointer.secondary_released());