Work on sampler nodes, fix slew limiter
This commit is contained in:
parent
93a29192fd
commit
2c0d53fb84
|
|
@ -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
|
||||
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::<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) => {
|
||||
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) => {
|
||||
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<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));
|
||||
}
|
||||
|
||||
/// 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<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));
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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<NodePort>,
|
||||
outputs: Vec<NodePort>,
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -332,7 +332,9 @@ where
|
|||
ports: &SlotMap<Key, Value>,
|
||||
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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
pub struct OscilloscopeCache {
|
||||
pub audio: 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.
|
||||
#[derive(Default)]
|
||||
pub struct GraphState {
|
||||
pub active_node: Option<NodeId>,
|
||||
/// Oscilloscope data cached per node, populated before draw_graph_editor()
|
||||
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)
|
||||
|
|
@ -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("");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in New Issue