Merge branch 'rust-ui' of https://git.skyler.io/skyler/Lightningbeam into rust-ui
This commit is contained in:
commit
2a94ac0f69
|
|
@ -1426,6 +1426,34 @@ impl Engine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Command::GraphSetGroups(track_id, groups) => {
|
||||||
|
let graph = match self.project.get_track_mut(track_id) {
|
||||||
|
Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph),
|
||||||
|
Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(graph) = graph {
|
||||||
|
graph.set_frontend_groups(groups);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::GraphSetGroupsInTemplate(track_id, voice_allocator_id, groups) => {
|
||||||
|
use crate::audio::node_graph::nodes::VoiceAllocatorNode;
|
||||||
|
let graph = match self.project.get_track_mut(track_id) {
|
||||||
|
Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph),
|
||||||
|
Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(graph) = graph {
|
||||||
|
let node_idx = NodeIndex::new(voice_allocator_id as usize);
|
||||||
|
if let Some(graph_node) = graph.get_node_mut(node_idx) {
|
||||||
|
if let Some(va_node) = graph_node.as_any_mut().downcast_mut::<VoiceAllocatorNode>() {
|
||||||
|
va_node.template_graph_mut().set_frontend_groups(groups);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Command::GraphSavePreset(track_id, preset_path, preset_name, description, tags) => {
|
Command::GraphSavePreset(track_id, preset_path, preset_name, description, tags) => {
|
||||||
let graph = match self.project.get_track(track_id) {
|
let graph = match self.project.get_track(track_id) {
|
||||||
Some(TrackNode::Midi(track)) => Some(&track.instrument_graph),
|
Some(TrackNode::Midi(track)) => Some(&track.instrument_graph),
|
||||||
|
|
@ -1942,6 +1970,18 @@ impl Engine {
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Query::GetVoiceOscilloscopeData(track_id, va_node_id, inner_node_id, sample_count) => {
|
||||||
|
match self.project.get_voice_oscilloscope_data(track_id, va_node_id, inner_node_id, sample_count) {
|
||||||
|
Some((audio, cv)) => {
|
||||||
|
use crate::command::OscilloscopeData;
|
||||||
|
QueryResponse::OscilloscopeData(Ok(OscilloscopeData { audio, cv }))
|
||||||
|
}
|
||||||
|
None => QueryResponse::OscilloscopeData(Err(format!(
|
||||||
|
"Failed to get voice oscilloscope data from track {} VA {} node {}",
|
||||||
|
track_id, va_node_id, inner_node_id
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
Query::GetMidiClip(_track_id, clip_id) => {
|
Query::GetMidiClip(_track_id, clip_id) => {
|
||||||
// Get MIDI clip data from the pool
|
// Get MIDI clip data from the pool
|
||||||
if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) {
|
if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) {
|
||||||
|
|
@ -3029,6 +3069,16 @@ impl EngineController {
|
||||||
let _ = self.command_tx.push(Command::GraphSetOutputNode(track_id, node_id));
|
let _ = self.command_tx.push(Command::GraphSetOutputNode(track_id, node_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set frontend-only group definitions on a track's graph
|
||||||
|
pub fn graph_set_groups(&mut self, track_id: TrackId, groups: Vec<crate::audio::node_graph::preset::SerializedGroup>) {
|
||||||
|
let _ = self.command_tx.push(Command::GraphSetGroups(track_id, groups));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set frontend-only group definitions on a VA template graph
|
||||||
|
pub fn graph_set_groups_in_template(&mut self, track_id: TrackId, voice_allocator_id: u32, groups: Vec<crate::audio::node_graph::preset::SerializedGroup>) {
|
||||||
|
let _ = self.command_tx.push(Command::GraphSetGroupsInTemplate(track_id, voice_allocator_id, groups));
|
||||||
|
}
|
||||||
|
|
||||||
/// Save the current graph as a preset
|
/// Save the current graph as a preset
|
||||||
pub fn graph_save_preset(&mut self, track_id: TrackId, preset_path: String, preset_name: String, description: String, tags: Vec<String>) {
|
pub fn graph_save_preset(&mut self, track_id: TrackId, preset_path: String, preset_name: String, description: String, tags: Vec<String>) {
|
||||||
let _ = self.command_tx.push(Command::GraphSavePreset(track_id, preset_path, preset_name, description, tags));
|
let _ = self.command_tx.push(Command::GraphSavePreset(track_id, preset_path, preset_name, description, tags));
|
||||||
|
|
@ -3177,6 +3227,25 @@ impl EngineController {
|
||||||
Err("Query timeout".to_string())
|
Err("Query timeout".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Query oscilloscope data from a node inside a VoiceAllocator's best voice
|
||||||
|
pub fn query_voice_oscilloscope_data(&mut self, track_id: TrackId, va_node_id: u32, inner_node_id: u32, sample_count: usize) -> Result<crate::command::OscilloscopeData, String> {
|
||||||
|
if let Err(_) = self.query_tx.push(Query::GetVoiceOscilloscopeData(track_id, va_node_id, inner_node_id, sample_count)) {
|
||||||
|
return Err("Failed to send query - queue full".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let timeout = std::time::Duration::from_millis(100);
|
||||||
|
|
||||||
|
while start.elapsed() < timeout {
|
||||||
|
if let Ok(QueryResponse::OscilloscopeData(result)) = self.query_response_rx.pop() {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_micros(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("Query timeout".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Query automation keyframes from an AutomationInput node
|
/// Query automation keyframes from an AutomationInput node
|
||||||
pub fn query_automation_keyframes(&mut self, track_id: TrackId, node_id: u32) -> Result<Vec<crate::command::types::AutomationKeyframeData>, String> {
|
pub fn query_automation_keyframes(&mut self, track_id: TrackId, node_id: u32) -> Result<Vec<crate::command::types::AutomationKeyframeData>, String> {
|
||||||
// Send query
|
// Send query
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,9 @@ pub struct AudioGraph {
|
||||||
|
|
||||||
/// Cached topological sort order (invalidated on graph mutation)
|
/// Cached topological sort order (invalidated on graph mutation)
|
||||||
topo_cache: Option<Vec<NodeIndex>>,
|
topo_cache: Option<Vec<NodeIndex>>,
|
||||||
|
|
||||||
|
/// Frontend-only group definitions (stored opaquely for persistence)
|
||||||
|
frontend_groups: Vec<crate::audio::node_graph::preset::SerializedGroup>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioGraph {
|
impl AudioGraph {
|
||||||
|
|
@ -117,6 +120,7 @@ impl AudioGraph {
|
||||||
node_positions: std::collections::HashMap::new(),
|
node_positions: std::collections::HashMap::new(),
|
||||||
playback_time: 0.0,
|
playback_time: 0.0,
|
||||||
topo_cache: None,
|
topo_cache: None,
|
||||||
|
frontend_groups: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -645,6 +649,10 @@ impl AudioGraph {
|
||||||
self.graph.node_weight(idx).map(|n| &*n.node)
|
self.graph.node_weight(idx).map(|n| &*n.node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_node_mut(&mut self, idx: NodeIndex) -> Option<&mut (dyn AudioNode + 'static)> {
|
||||||
|
self.graph.node_weight_mut(idx).map(|n| &mut *n.node)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get oscilloscope data from a specific node
|
/// Get oscilloscope data from a specific node
|
||||||
pub fn get_oscilloscope_data(&self, idx: NodeIndex, sample_count: usize) -> Option<Vec<f32>> {
|
pub fn get_oscilloscope_data(&self, idx: NodeIndex, sample_count: usize) -> Option<Vec<f32>> {
|
||||||
self.get_node(idx).and_then(|node| node.get_oscilloscope_data(sample_count))
|
self.get_node(idx).and_then(|node| node.get_oscilloscope_data(sample_count))
|
||||||
|
|
@ -729,9 +737,17 @@ impl AudioGraph {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clone frontend groups
|
||||||
|
new_graph.frontend_groups = self.frontend_groups.clone();
|
||||||
|
|
||||||
new_graph
|
new_graph
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set frontend-only group definitions (stored opaquely for persistence)
|
||||||
|
pub fn set_frontend_groups(&mut self, groups: Vec<crate::audio::node_graph::preset::SerializedGroup>) {
|
||||||
|
self.frontend_groups = groups;
|
||||||
|
}
|
||||||
|
|
||||||
/// Serialize the graph to a preset
|
/// Serialize the graph to a preset
|
||||||
pub fn to_preset(&self, name: impl Into<String>) -> crate::audio::node_graph::preset::GraphPreset {
|
pub fn to_preset(&self, name: impl Into<String>) -> crate::audio::node_graph::preset::GraphPreset {
|
||||||
use crate::audio::node_graph::preset::{GraphPreset, SerializedConnection, SerializedNode};
|
use crate::audio::node_graph::preset::{GraphPreset, SerializedConnection, SerializedNode};
|
||||||
|
|
@ -897,6 +913,9 @@ impl AudioGraph {
|
||||||
// Output node
|
// Output node
|
||||||
preset.output_node = self.output_node.map(|idx| idx.index() as u32);
|
preset.output_node = self.output_node.map(|idx| idx.index() as u32);
|
||||||
|
|
||||||
|
// Frontend groups (stored opaquely)
|
||||||
|
preset.groups = self.frontend_groups.clone();
|
||||||
|
|
||||||
preset
|
preset
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1118,6 +1137,9 @@ impl AudioGraph {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore frontend groups (stored opaquely)
|
||||||
|
graph.frontend_groups = preset.groups.clone();
|
||||||
|
|
||||||
Ok(graph)
|
Ok(graph)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,5 @@ pub mod preset;
|
||||||
|
|
||||||
pub use graph::{Connection, GraphNode, AudioGraph};
|
pub use graph::{Connection, GraphNode, AudioGraph};
|
||||||
pub use node_trait::{AudioNode, cv_input_or_default};
|
pub use node_trait::{AudioNode, cv_input_or_default};
|
||||||
pub use preset::{GraphPreset, PresetMetadata, SerializedConnection, SerializedNode};
|
pub use preset::{GraphPreset, PresetMetadata, SerializedConnection, SerializedNode, SerializedGroup, SerializedBoundaryConnection};
|
||||||
pub use types::{ConnectionError, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
pub use types::{ConnectionError, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ pub struct FilterNode {
|
||||||
resonance: f32,
|
resonance: f32,
|
||||||
filter_type: FilterType,
|
filter_type: FilterType,
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
|
/// Last cutoff frequency applied to filter coefficients (for change detection with CV modulation)
|
||||||
|
last_applied_cutoff: f32,
|
||||||
inputs: Vec<NodePort>,
|
inputs: Vec<NodePort>,
|
||||||
outputs: Vec<NodePort>,
|
outputs: Vec<NodePort>,
|
||||||
parameters: Vec<Parameter>,
|
parameters: Vec<Parameter>,
|
||||||
|
|
@ -62,6 +64,7 @@ impl FilterNode {
|
||||||
resonance: 0.707,
|
resonance: 0.707,
|
||||||
filter_type: FilterType::Lowpass,
|
filter_type: FilterType::Lowpass,
|
||||||
sample_rate: 44100,
|
sample_rate: 44100,
|
||||||
|
last_applied_cutoff: 1000.0,
|
||||||
inputs,
|
inputs,
|
||||||
outputs,
|
outputs,
|
||||||
parameters,
|
parameters,
|
||||||
|
|
@ -150,11 +153,20 @@ impl AudioNode for FilterNode {
|
||||||
output[..len].copy_from_slice(&input[..len]);
|
output[..len].copy_from_slice(&input[..len]);
|
||||||
|
|
||||||
// Check for CV modulation (modulates cutoff)
|
// Check for CV modulation (modulates cutoff)
|
||||||
|
// CV input (0..1) scales the cutoff: 0 = 20 Hz, 1 = base cutoff * 2
|
||||||
// Sample CV at the start of the buffer - per-sample would be too expensive
|
// Sample CV at the start of the buffer - per-sample would be too expensive
|
||||||
let cutoff_cv = cv_input_or_default(inputs, 1, 0, self.cutoff);
|
let cutoff_cv_raw = cv_input_or_default(inputs, 1, 0, f32::NAN);
|
||||||
if (cutoff_cv - self.cutoff).abs() > 0.01 {
|
let effective_cutoff = if cutoff_cv_raw.is_nan() {
|
||||||
// CV changed significantly, update filter
|
self.cutoff
|
||||||
let new_cutoff = cutoff_cv.clamp(20.0, 20000.0);
|
} else {
|
||||||
|
// Map CV (0..1) to frequency range around the base cutoff
|
||||||
|
// 0.5 = base cutoff, 0 = cutoff / 4, 1 = cutoff * 4 (two octaves each way)
|
||||||
|
let octave_shift = (cutoff_cv_raw.clamp(0.0, 1.0) - 0.5) * 4.0;
|
||||||
|
self.cutoff * 2.0_f32.powf(octave_shift)
|
||||||
|
};
|
||||||
|
if (effective_cutoff - self.last_applied_cutoff).abs() > 0.01 {
|
||||||
|
let new_cutoff = effective_cutoff.clamp(20.0, 20000.0);
|
||||||
|
self.last_applied_cutoff = new_cutoff;
|
||||||
match self.filter_type {
|
match self.filter_type {
|
||||||
FilterType::Lowpass => {
|
FilterType::Lowpass => {
|
||||||
self.filter.set_lowpass(new_cutoff, self.resonance, self.sample_rate as f32);
|
self.filter.set_lowpass(new_cutoff, self.resonance, self.sample_rate as f32);
|
||||||
|
|
@ -202,6 +214,7 @@ impl AudioNode for FilterNode {
|
||||||
resonance: self.resonance,
|
resonance: self.resonance,
|
||||||
filter_type: self.filter_type,
|
filter_type: self.filter_type,
|
||||||
sample_rate: self.sample_rate,
|
sample_rate: self.sample_rate,
|
||||||
|
last_applied_cutoff: self.cutoff,
|
||||||
inputs: self.inputs.clone(),
|
inputs: self.inputs.clone(),
|
||||||
outputs: self.outputs.clone(),
|
outputs: self.outputs.clone(),
|
||||||
parameters: self.parameters.clone(),
|
parameters: self.parameters.clone(),
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,35 @@ impl VoiceAllocatorNode {
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get oscilloscope data from the most relevant voice's subgraph.
|
||||||
|
/// Priority: first active voice → first releasing voice → first voice.
|
||||||
|
pub fn get_voice_oscilloscope_data(&self, node_id: u32, sample_count: usize) -> Option<(Vec<f32>, Vec<f32>)> {
|
||||||
|
let voice_idx = self.best_voice_index();
|
||||||
|
let graph = &self.voice_instances[voice_idx];
|
||||||
|
let node_idx = petgraph::stable_graph::NodeIndex::new(node_id as usize);
|
||||||
|
let audio = graph.get_oscilloscope_data(node_idx, sample_count)?;
|
||||||
|
let cv = graph.get_oscilloscope_cv_data(node_idx, sample_count).unwrap_or_default();
|
||||||
|
Some((audio, cv))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the best voice index to observe: first active → first releasing → 0
|
||||||
|
fn best_voice_index(&self) -> usize {
|
||||||
|
// First active (non-releasing) voice
|
||||||
|
for (i, v) in self.voices[..self.voice_count].iter().enumerate() {
|
||||||
|
if v.active && !v.releasing {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// First releasing voice
|
||||||
|
for (i, v) in self.voices[..self.voice_count].iter().enumerate() {
|
||||||
|
if v.active && v.releasing {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to first voice
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
/// Find all voices playing a specific note (held, not yet releasing)
|
/// Find all voices playing a specific note (held, not yet releasing)
|
||||||
fn find_voices_for_note_off(&self, note: u8) -> Vec<usize> {
|
fn find_voices_for_note_off(&self, note: u8) -> Vec<usize> {
|
||||||
self.voices[..self.voice_count]
|
self.voices[..self.voice_count]
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,10 @@ pub struct GraphPreset {
|
||||||
|
|
||||||
/// Which node index is the audio output (None if not set)
|
/// Which node index is the audio output (None if not set)
|
||||||
pub output_node: Option<u32>,
|
pub output_node: Option<u32>,
|
||||||
|
|
||||||
|
/// Frontend-only group definitions (backend stores opaquely, does not interpret)
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub groups: Vec<SerializedGroup>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Metadata about the preset
|
/// Metadata about the preset
|
||||||
|
|
@ -121,6 +125,32 @@ pub struct SerializedNode {
|
||||||
pub sample_data: Option<SampleData>,
|
pub sample_data: Option<SampleData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialized group definition (frontend-only visual grouping, stored opaquely by backend)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SerializedGroup {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub member_nodes: Vec<u32>,
|
||||||
|
pub position: (f32, f32),
|
||||||
|
pub boundary_inputs: Vec<SerializedBoundaryConnection>,
|
||||||
|
pub boundary_outputs: Vec<SerializedBoundaryConnection>,
|
||||||
|
/// Parent group ID for nested groups (None = top-level group)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_group_id: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialized boundary connection for group definitions
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SerializedBoundaryConnection {
|
||||||
|
pub external_node: u32,
|
||||||
|
pub external_port: usize,
|
||||||
|
pub internal_node: u32,
|
||||||
|
pub internal_port: usize,
|
||||||
|
pub port_name: String,
|
||||||
|
/// Signal type as string ("Audio", "Midi", "CV")
|
||||||
|
pub data_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Serialized connection between nodes
|
/// Serialized connection between nodes
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SerializedConnection {
|
pub struct SerializedConnection {
|
||||||
|
|
@ -152,6 +182,7 @@ impl GraphPreset {
|
||||||
connections: Vec::new(),
|
connections: Vec::new(),
|
||||||
midi_targets: Vec::new(),
|
midi_targets: Vec::new(),
|
||||||
output_node: None,
|
output_node: None,
|
||||||
|
groups: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,18 @@ impl Project {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get oscilloscope data from a node inside a VoiceAllocator's best voice
|
||||||
|
pub fn get_voice_oscilloscope_data(&self, track_id: TrackId, va_node_id: u32, inner_node_id: u32, sample_count: usize) -> Option<(Vec<f32>, Vec<f32>)> {
|
||||||
|
if let Some(TrackNode::Midi(track)) = self.tracks.get(&track_id) {
|
||||||
|
let graph = &track.instrument_graph;
|
||||||
|
let va_idx = petgraph::stable_graph::NodeIndex::new(va_node_id as usize);
|
||||||
|
let node = graph.get_node(va_idx)?;
|
||||||
|
let va = node.as_any().downcast_ref::<crate::audio::node_graph::nodes::VoiceAllocatorNode>()?;
|
||||||
|
return va.get_voice_oscilloscope_data(inner_node_id, sample_count);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Get all root-level track IDs
|
/// Get all root-level track IDs
|
||||||
pub fn root_tracks(&self) -> &[TrackId] {
|
pub fn root_tracks(&self) -> &[TrackId] {
|
||||||
&self.root_tracks
|
&self.root_tracks
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,11 @@ pub enum Command {
|
||||||
/// Set which node is the audio output (track_id, node_index)
|
/// Set which node is the audio output (track_id, node_index)
|
||||||
GraphSetOutputNode(TrackId, u32),
|
GraphSetOutputNode(TrackId, u32),
|
||||||
|
|
||||||
|
/// Set frontend-only group definitions on a track's graph (track_id, serialized groups)
|
||||||
|
GraphSetGroups(TrackId, Vec<crate::audio::node_graph::preset::SerializedGroup>),
|
||||||
|
/// Set frontend-only group definitions on a VA template graph (track_id, voice_allocator_id, serialized groups)
|
||||||
|
GraphSetGroupsInTemplate(TrackId, u32, Vec<crate::audio::node_graph::preset::SerializedGroup>),
|
||||||
|
|
||||||
/// Save current graph as a preset (track_id, preset_path, preset_name, description, tags)
|
/// Save current graph as a preset (track_id, preset_path, preset_name, description, tags)
|
||||||
GraphSavePreset(TrackId, String, String, String, Vec<String>),
|
GraphSavePreset(TrackId, String, String, String, Vec<String>),
|
||||||
/// Load a preset into a track's graph (track_id, preset_path)
|
/// Load a preset into a track's graph (track_id, preset_path)
|
||||||
|
|
@ -314,6 +319,9 @@ pub enum Query {
|
||||||
GetTemplateState(TrackId, u32),
|
GetTemplateState(TrackId, u32),
|
||||||
/// Get oscilloscope data from a node (track_id, node_id, sample_count)
|
/// Get oscilloscope data from a node (track_id, node_id, sample_count)
|
||||||
GetOscilloscopeData(TrackId, u32, usize),
|
GetOscilloscopeData(TrackId, u32, usize),
|
||||||
|
/// Get oscilloscope data from a node inside a VoiceAllocator's best voice
|
||||||
|
/// (track_id, va_node_id, inner_node_id, sample_count)
|
||||||
|
GetVoiceOscilloscopeData(TrackId, u32, u32, usize),
|
||||||
/// Get MIDI clip data (track_id, clip_id)
|
/// Get MIDI clip data (track_id, clip_id)
|
||||||
GetMidiClip(TrackId, MidiClipId),
|
GetMidiClip(TrackId, MidiClipId),
|
||||||
/// Get keyframes from an AutomationInput node (track_id, node_id)
|
/// Get keyframes from an AutomationInput node (track_id, node_id)
|
||||||
|
|
|
||||||
|
|
@ -1799,8 +1799,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ecolor"
|
name = "ecolor"
|
||||||
version = "0.33.3"
|
version = "0.33.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"emath",
|
"emath",
|
||||||
|
|
@ -1810,8 +1808,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "eframe"
|
name = "eframe"
|
||||||
version = "0.33.3"
|
version = "0.33.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash 0.8.12",
|
"ahash 0.8.12",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
|
|
@ -1847,8 +1843,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui"
|
name = "egui"
|
||||||
version = "0.33.3"
|
version = "0.33.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"ahash 0.8.12",
|
"ahash 0.8.12",
|
||||||
|
|
@ -1867,8 +1861,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui-wgpu"
|
name = "egui-wgpu"
|
||||||
version = "0.33.3"
|
version = "0.33.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash 0.8.12",
|
"ahash 0.8.12",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
|
|
@ -1887,8 +1879,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui-winit"
|
name = "egui-winit"
|
||||||
version = "0.33.3"
|
version = "0.33.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit_winit",
|
"accesskit_winit",
|
||||||
"arboard",
|
"arboard",
|
||||||
|
|
@ -1918,8 +1908,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui_extras"
|
name = "egui_extras"
|
||||||
version = "0.33.3"
|
version = "0.33.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d01d34e845f01c62e3fded726961092e70417d66570c499b9817ab24674ca4ed"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash 0.8.12",
|
"ahash 0.8.12",
|
||||||
"egui",
|
"egui",
|
||||||
|
|
@ -1935,8 +1923,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui_glow"
|
name = "egui_glow"
|
||||||
version = "0.33.3"
|
version = "0.33.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"egui",
|
"egui",
|
||||||
|
|
@ -1969,8 +1955,6 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "emath"
|
name = "emath"
|
||||||
version = "0.33.3"
|
version = "0.33.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -2046,8 +2030,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "epaint"
|
name = "epaint"
|
||||||
version = "0.33.3"
|
version = "0.33.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"ahash 0.8.12",
|
"ahash 0.8.12",
|
||||||
|
|
@ -2065,8 +2047,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "epaint_default_fonts"
|
name = "epaint_default_fonts"
|
||||||
version = "0.33.3"
|
version = "0.33.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equator"
|
name = "equator"
|
||||||
|
|
|
||||||
|
|
@ -69,3 +69,14 @@ opt-level = 2
|
||||||
opt-level = 2
|
opt-level = 2
|
||||||
[profile.dev.package.cpal]
|
[profile.dev.package.cpal]
|
||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|
||||||
|
# Use local egui fork with ibus/Wayland text input fix
|
||||||
|
[patch.crates-io]
|
||||||
|
egui = { path = "../../egui-fork/crates/egui" }
|
||||||
|
eframe = { path = "../../egui-fork/crates/eframe" }
|
||||||
|
egui_extras = { path = "../../egui-fork/crates/egui_extras" }
|
||||||
|
egui-wgpu = { path = "../../egui-fork/crates/egui-wgpu" }
|
||||||
|
egui-winit = { path = "../../egui-fork/crates/egui-winit" }
|
||||||
|
epaint = { path = "../../egui-fork/crates/epaint" }
|
||||||
|
ecolor = { path = "../../egui-fork/crates/ecolor" }
|
||||||
|
emath = { path = "../../egui-fork/crates/emath" }
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ pub struct GraphResponse<UserResponse: UserResponseTrait, NodeData: NodeDataTrai
|
||||||
pub cursor_in_editor: bool,
|
pub cursor_in_editor: bool,
|
||||||
/// Is the mouse currently hovering the node finder?
|
/// Is the mouse currently hovering the node finder?
|
||||||
pub cursor_in_finder: bool,
|
pub cursor_in_finder: bool,
|
||||||
|
/// Screen-space rects of all rendered nodes (for hit-testing)
|
||||||
|
pub node_rects: NodeRects,
|
||||||
}
|
}
|
||||||
impl<UserResponse: UserResponseTrait, NodeData: NodeDataTrait> Default
|
impl<UserResponse: UserResponseTrait, NodeData: NodeDataTrait> Default
|
||||||
for GraphResponse<UserResponse, NodeData>
|
for GraphResponse<UserResponse, NodeData>
|
||||||
|
|
@ -89,6 +91,7 @@ impl<UserResponse: UserResponseTrait, NodeData: NodeDataTrait> Default
|
||||||
node_responses: Default::default(),
|
node_responses: Default::default(),
|
||||||
cursor_in_editor: false,
|
cursor_in_editor: false,
|
||||||
cursor_in_finder: false,
|
cursor_in_finder: false,
|
||||||
|
node_rects: NodeRects::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -507,8 +510,8 @@ where
|
||||||
);
|
);
|
||||||
|
|
||||||
self.selected_nodes = node_rects
|
self.selected_nodes = node_rects
|
||||||
.into_iter()
|
.iter()
|
||||||
.filter_map(|(node_id, rect)| {
|
.filter_map(|(&node_id, &rect)| {
|
||||||
if selection_rect.intersects(rect) {
|
if selection_rect.intersects(rect) {
|
||||||
Some(node_id)
|
Some(node_id)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -568,6 +571,7 @@ where
|
||||||
node_responses: delayed_responses,
|
node_responses: delayed_responses,
|
||||||
cursor_in_editor,
|
cursor_in_editor,
|
||||||
cursor_in_finder,
|
cursor_in_finder,
|
||||||
|
node_rects,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ impl PaneType {
|
||||||
PaneType::PianoRoll => "Piano Roll",
|
PaneType::PianoRoll => "Piano Roll",
|
||||||
PaneType::VirtualPiano => "Virtual Piano",
|
PaneType::VirtualPiano => "Virtual Piano",
|
||||||
PaneType::NodeEditor => "Node Editor",
|
PaneType::NodeEditor => "Node Editor",
|
||||||
PaneType::PresetBrowser => "Preset Browser",
|
PaneType::PresetBrowser => "Instrument Browser",
|
||||||
PaneType::AssetLibrary => "Asset Library",
|
PaneType::AssetLibrary => "Asset Library",
|
||||||
PaneType::ShaderEditor => "Shader Editor",
|
PaneType::ShaderEditor => "Shader Editor",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4384,7 +4384,7 @@ impl eframe::App for EditorApp {
|
||||||
pending_menu_actions: &mut pending_menu_actions,
|
pending_menu_actions: &mut pending_menu_actions,
|
||||||
clipboard_manager: &mut self.clipboard_manager,
|
clipboard_manager: &mut self.clipboard_manager,
|
||||||
waveform_stereo: self.config.waveform_stereo,
|
waveform_stereo: self.config.waveform_stereo,
|
||||||
project_generation: self.project_generation,
|
project_generation: &mut self.project_generation,
|
||||||
};
|
};
|
||||||
|
|
||||||
render_layout_node(
|
render_layout_node(
|
||||||
|
|
@ -4660,7 +4660,7 @@ struct RenderContext<'a> {
|
||||||
/// Whether to show waveforms as stacked stereo
|
/// Whether to show waveforms as stacked stereo
|
||||||
waveform_stereo: bool,
|
waveform_stereo: bool,
|
||||||
/// Project generation counter (incremented on load)
|
/// Project generation counter (incremented on load)
|
||||||
project_generation: u64,
|
project_generation: &'a mut u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively render a layout node with drag support
|
/// Recursively render a layout node with drag support
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ pub struct SharedPaneState<'a> {
|
||||||
/// Whether to show waveforms as stacked stereo (true) or combined mono (false)
|
/// Whether to show waveforms as stacked stereo (true) or combined mono (false)
|
||||||
pub waveform_stereo: bool,
|
pub waveform_stereo: bool,
|
||||||
/// Generation counter - incremented on project load to force reloads
|
/// Generation counter - incremented on project load to force reloads
|
||||||
pub project_generation: u64,
|
pub project_generation: &'a mut u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trait for pane rendering
|
/// Trait for pane rendering
|
||||||
|
|
|
||||||
|
|
@ -215,4 +215,9 @@ impl GraphBackend for AudioGraphBackend {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn query_template_state(&self, voice_allocator_id: u32) -> Result<String, String> {
|
||||||
|
let mut controller = self.audio_controller.lock().unwrap();
|
||||||
|
controller.query_template_state(self.track_id, voice_allocator_id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,9 @@ pub trait GraphBackend: Send {
|
||||||
input_node: BackendNodeId,
|
input_node: BackendNodeId,
|
||||||
input_port: usize,
|
input_port: usize,
|
||||||
) -> Result<(), String>;
|
) -> Result<(), String>;
|
||||||
|
|
||||||
|
/// Get the state of a VoiceAllocator's template graph as JSON
|
||||||
|
fn query_template_state(&self, voice_allocator_id: u32) -> Result<String, String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serializable graph state (for presets and save/load)
|
/// Serializable graph state (for presets and save/load)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use egui_node_graph2::*;
|
use egui_node_graph2::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Signal types for audio node graph
|
/// Signal types for audio node graph
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -136,10 +137,18 @@ pub struct NodeData {
|
||||||
pub template: NodeTemplate,
|
pub template: NodeTemplate,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cached oscilloscope waveform data for rendering in node body
|
||||||
|
pub struct OscilloscopeCache {
|
||||||
|
pub audio: Vec<f32>,
|
||||||
|
pub cv: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Custom graph state - can track selected nodes, etc.
|
/// Custom graph state - can track selected nodes, etc.
|
||||||
#[derive(Default)]
|
#[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()
|
||||||
|
pub oscilloscope_data: HashMap<NodeId, OscilloscopeCache>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User response type (empty for now)
|
/// User response type (empty for now)
|
||||||
|
|
@ -782,15 +791,52 @@ impl NodeDataTrait for NodeData {
|
||||||
fn bottom_ui(
|
fn bottom_ui(
|
||||||
&self,
|
&self,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
_node_id: NodeId,
|
node_id: NodeId,
|
||||||
_graph: &Graph<NodeData, DataType, ValueType>,
|
_graph: &Graph<NodeData, DataType, ValueType>,
|
||||||
_user_state: &mut Self::UserState,
|
user_state: &mut Self::UserState,
|
||||||
) -> Vec<NodeResponse<Self::Response, NodeData>>
|
) -> Vec<NodeResponse<Self::Response, NodeData>>
|
||||||
where
|
where
|
||||||
Self::Response: UserResponseTrait,
|
Self::Response: UserResponseTrait,
|
||||||
{
|
{
|
||||||
// No custom UI for now
|
if self.template == NodeTemplate::Oscilloscope {
|
||||||
ui.label("");
|
let size = egui::vec2(200.0, 80.0);
|
||||||
|
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
|
||||||
|
let painter = ui.painter_at(rect);
|
||||||
|
|
||||||
|
// Background
|
||||||
|
painter.rect_filled(rect, 2.0, egui::Color32::from_rgb(0x1a, 0x1a, 0x1a));
|
||||||
|
|
||||||
|
// Center line
|
||||||
|
let center_y = rect.center().y;
|
||||||
|
painter.line_segment(
|
||||||
|
[egui::pos2(rect.left(), center_y), egui::pos2(rect.right(), center_y)],
|
||||||
|
egui::Stroke::new(1.0, egui::Color32::from_rgb(0x2a, 0x2a, 0x2a)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(cache) = user_state.oscilloscope_data.get(&node_id) {
|
||||||
|
// Draw audio waveform (green)
|
||||||
|
if cache.audio.len() >= 2 {
|
||||||
|
let points: Vec<egui::Pos2> = cache.audio.iter().enumerate().map(|(i, &sample)| {
|
||||||
|
let x = rect.left() + (i as f32 / (cache.audio.len() - 1) as f32) * rect.width();
|
||||||
|
let y = center_y - sample.clamp(-1.0, 1.0) * (rect.height() / 2.0);
|
||||||
|
egui::pos2(x, y)
|
||||||
|
}).collect();
|
||||||
|
painter.add(egui::Shape::line(points, egui::Stroke::new(1.5, egui::Color32::from_rgb(0x4C, 0xAF, 0x50))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw CV waveform (orange) if present
|
||||||
|
if cache.cv.len() >= 2 {
|
||||||
|
let points: Vec<egui::Pos2> = cache.cv.iter().enumerate().map(|(i, &sample)| {
|
||||||
|
let x = rect.left() + (i as f32 / (cache.cv.len() - 1) as f32) * rect.width();
|
||||||
|
let y = center_y - sample.clamp(-1.0, 1.0) * (rect.height() / 2.0);
|
||||||
|
egui::pos2(x, y)
|
||||||
|
}).collect();
|
||||||
|
painter.add(egui::Shape::line(points, egui::Stroke::new(1.5, egui::Color32::from_rgb(0xFF, 0x98, 0x00))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ui.label("");
|
||||||
|
}
|
||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -801,6 +847,22 @@ pub struct AllNodeTemplates;
|
||||||
/// Iterator for subgraph node templates (includes TemplateInput/Output)
|
/// Iterator for subgraph node templates (includes TemplateInput/Output)
|
||||||
pub struct SubgraphNodeTemplates;
|
pub struct SubgraphNodeTemplates;
|
||||||
|
|
||||||
|
/// Node templates available inside a VoiceAllocator subgraph (no nested VA)
|
||||||
|
pub struct VoiceAllocatorNodeTemplates;
|
||||||
|
|
||||||
|
impl NodeTemplateIter for VoiceAllocatorNodeTemplates {
|
||||||
|
type Item = NodeTemplate;
|
||||||
|
|
||||||
|
fn all_kinds(&self) -> Vec<Self::Item> {
|
||||||
|
let mut templates = AllNodeTemplates.all_kinds();
|
||||||
|
// VA nodes can't be nested — signals inside a VA are monophonic
|
||||||
|
templates.retain(|t| *t != NodeTemplate::VoiceAllocator);
|
||||||
|
templates.push(NodeTemplate::TemplateInput);
|
||||||
|
templates.push(NodeTemplate::TemplateOutput);
|
||||||
|
templates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl NodeTemplateIter for SubgraphNodeTemplates {
|
impl NodeTemplateIter for SubgraphNodeTemplates {
|
||||||
type Item = NodeTemplate;
|
type Item = NodeTemplate;
|
||||||
|
|
||||||
|
|
@ -863,7 +925,7 @@ impl NodeTemplateIter for AllNodeTemplates {
|
||||||
NodeTemplate::Oscilloscope,
|
NodeTemplate::Oscilloscope,
|
||||||
// Advanced
|
// Advanced
|
||||||
NodeTemplate::VoiceAllocator,
|
NodeTemplate::VoiceAllocator,
|
||||||
NodeTemplate::Group,
|
// Note: Group is not in the node finder — groups are created via Ctrl+G selection.
|
||||||
// Note: TemplateInput/TemplateOutput are excluded from the default finder.
|
// Note: TemplateInput/TemplateOutput are excluded from the default finder.
|
||||||
// They are added dynamically when editing inside a subgraph.
|
// They are added dynamically when editing inside a subgraph.
|
||||||
// Outputs
|
// Outputs
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,45 +1,512 @@
|
||||||
/// Preset Browser pane - asset and preset library
|
/// Instrument Browser pane — browse, search, load, and save instrument presets
|
||||||
///
|
///
|
||||||
/// This will eventually show a file browser for presets.
|
/// Scans factory presets from `src/assets/instruments/` organized by category.
|
||||||
/// For now, it's a placeholder.
|
/// Presets are loaded into the currently selected track's audio graph.
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
use std::path::PathBuf;
|
||||||
use super::{NodePath, PaneRenderer, SharedPaneState};
|
use super::{NodePath, PaneRenderer, SharedPaneState};
|
||||||
|
|
||||||
pub struct PresetBrowserPane {}
|
/// Metadata extracted from a preset file
|
||||||
|
struct PresetInfo {
|
||||||
|
name: String,
|
||||||
|
path: PathBuf,
|
||||||
|
category: String,
|
||||||
|
description: String,
|
||||||
|
author: String,
|
||||||
|
tags: Vec<String>,
|
||||||
|
is_factory: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl PresetBrowserPane {
|
/// State for the save-preset dialog
|
||||||
pub fn new() -> Self {
|
struct SaveDialogState {
|
||||||
Self {}
|
name: String,
|
||||||
|
description: String,
|
||||||
|
tags_str: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SaveDialogState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
name: String::new(),
|
||||||
|
description: String::new(),
|
||||||
|
tags_str: String::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct PresetBrowserPane {
|
||||||
|
presets: Vec<PresetInfo>,
|
||||||
|
search_query: String,
|
||||||
|
/// Index into `self.presets` of the currently selected preset
|
||||||
|
selected_index: Option<usize>,
|
||||||
|
selected_category: Option<String>,
|
||||||
|
needs_reload: bool,
|
||||||
|
save_dialog: Option<SaveDialogState>,
|
||||||
|
/// Sorted unique category names extracted from presets
|
||||||
|
categories: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresetBrowserPane {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
presets: Vec::new(),
|
||||||
|
search_query: String::new(),
|
||||||
|
selected_index: None,
|
||||||
|
selected_category: None,
|
||||||
|
needs_reload: true,
|
||||||
|
save_dialog: None,
|
||||||
|
categories: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan preset directories and populate the preset list
|
||||||
|
fn scan_presets(&mut self) {
|
||||||
|
self.presets.clear();
|
||||||
|
self.categories.clear();
|
||||||
|
|
||||||
|
// Factory presets: resolve from CARGO_MANIFEST_DIR (lightningbeam-editor crate)
|
||||||
|
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let factory_dir = manifest_dir.join("../../src/assets/instruments");
|
||||||
|
|
||||||
|
if let Ok(factory_dir) = factory_dir.canonicalize() {
|
||||||
|
self.scan_directory(&factory_dir, &factory_dir, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort presets alphabetically by name within each category
|
||||||
|
self.presets.sort_by(|a, b| {
|
||||||
|
a.category.cmp(&b.category).then(a.name.cmp(&b.name))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract unique categories
|
||||||
|
let mut cats: Vec<String> = self.presets.iter()
|
||||||
|
.map(|p| p.category.clone())
|
||||||
|
.collect();
|
||||||
|
cats.sort();
|
||||||
|
cats.dedup();
|
||||||
|
self.categories = cats;
|
||||||
|
|
||||||
|
self.needs_reload = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively scan a directory for .json preset files
|
||||||
|
fn scan_directory(&mut self, dir: &std::path::Path, base_dir: &std::path::Path, is_factory: bool) {
|
||||||
|
let entries = match std::fs::read_dir(dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
self.scan_directory(&path, base_dir, is_factory);
|
||||||
|
} else if path.extension().is_some_and(|e| e == "json") {
|
||||||
|
if let Some(info) = self.load_preset_info(&path, base_dir, is_factory) {
|
||||||
|
self.presets.push(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load metadata from a preset JSON file
|
||||||
|
fn load_preset_info(&self, path: &std::path::Path, base_dir: &std::path::Path, is_factory: bool) -> Option<PresetInfo> {
|
||||||
|
let contents = std::fs::read_to_string(path).ok()?;
|
||||||
|
let preset: daw_backend::audio::node_graph::GraphPreset =
|
||||||
|
serde_json::from_str(&contents).ok()?;
|
||||||
|
|
||||||
|
// Category = first directory component relative to base_dir
|
||||||
|
let relative = path.strip_prefix(base_dir).ok()?;
|
||||||
|
let category = relative.components().next()
|
||||||
|
.and_then(|c| c.as_os_str().to_str())
|
||||||
|
.unwrap_or("other")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Some(PresetInfo {
|
||||||
|
name: preset.metadata.name,
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
category,
|
||||||
|
description: preset.metadata.description,
|
||||||
|
author: preset.metadata.author,
|
||||||
|
tags: preset.metadata.tags,
|
||||||
|
is_factory,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get indices of presets matching the current search query and category filter
|
||||||
|
fn filtered_indices(&self) -> Vec<usize> {
|
||||||
|
let query = self.search_query.to_lowercase();
|
||||||
|
self.presets.iter().enumerate()
|
||||||
|
.filter(|(_, p)| {
|
||||||
|
// Category filter
|
||||||
|
if let Some(ref cat) = self.selected_category {
|
||||||
|
if &p.category != cat {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Search filter
|
||||||
|
if !query.is_empty() {
|
||||||
|
let name_match = p.name.to_lowercase().contains(&query);
|
||||||
|
let desc_match = p.description.to_lowercase().contains(&query);
|
||||||
|
let tag_match = p.tags.iter().any(|t| t.to_lowercase().contains(&query));
|
||||||
|
if !name_match && !desc_match && !tag_match {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the selected preset into the current track
|
||||||
|
fn load_preset(&self, preset_index: usize, shared: &mut SharedPaneState) {
|
||||||
|
let preset = &self.presets[preset_index];
|
||||||
|
|
||||||
|
let track_id = match shared.active_layer_id.and_then(|lid| shared.layer_to_track_map.get(&lid)) {
|
||||||
|
Some(&tid) => tid,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(audio_controller) = &shared.audio_controller {
|
||||||
|
let mut controller = audio_controller.lock().unwrap();
|
||||||
|
controller.graph_load_preset(track_id, preset.path.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
*shared.project_generation += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the save preset dialog
|
||||||
|
fn render_save_dialog(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) {
|
||||||
|
let dialog = match &mut self.save_dialog {
|
||||||
|
Some(d) => d,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.heading("Save Preset");
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Name:");
|
||||||
|
ui.text_edit_singleline(&mut dialog.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label("Description:");
|
||||||
|
ui.add(egui::TextEdit::multiline(&mut dialog.description)
|
||||||
|
.desired_rows(3)
|
||||||
|
.desired_width(f32::INFINITY));
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Tags:");
|
||||||
|
ui.text_edit_singleline(&mut dialog.tags_str);
|
||||||
|
});
|
||||||
|
ui.label(egui::RichText::new("Comma-separated, e.g. bass, synth, warm")
|
||||||
|
.small()
|
||||||
|
.color(ui.visuals().weak_text_color()));
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
let name_valid = !dialog.name.trim().is_empty();
|
||||||
|
let mut do_save = false;
|
||||||
|
let mut do_cancel = false;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.add_enabled(name_valid, egui::Button::new("Save")).clicked() {
|
||||||
|
do_save = true;
|
||||||
|
}
|
||||||
|
if ui.button("Cancel").clicked() {
|
||||||
|
do_cancel = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act after dialog borrow is released
|
||||||
|
if do_save {
|
||||||
|
self.do_save_preset(shared);
|
||||||
|
} else if do_cancel {
|
||||||
|
self.save_dialog = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute the save action
|
||||||
|
fn do_save_preset(&mut self, shared: &mut SharedPaneState) {
|
||||||
|
let dialog = match self.save_dialog.take() {
|
||||||
|
Some(d) => d,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let track_id = match shared.active_layer_id.and_then(|lid| shared.layer_to_track_map.get(&lid)) {
|
||||||
|
Some(&tid) => tid,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = dialog.name.trim().to_string();
|
||||||
|
let description = dialog.description.trim().to_string();
|
||||||
|
let tags: Vec<String> = dialog.tags_str.split(',')
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Save to user presets directory
|
||||||
|
let save_dir = user_presets_dir();
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&save_dir) {
|
||||||
|
eprintln!("Failed to create presets directory: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = sanitize_filename(&name);
|
||||||
|
let save_path = save_dir.join(format!("{}.json", filename));
|
||||||
|
|
||||||
|
if let Some(audio_controller) = &shared.audio_controller {
|
||||||
|
let mut controller = audio_controller.lock().unwrap();
|
||||||
|
controller.graph_save_preset(
|
||||||
|
track_id,
|
||||||
|
save_path.to_string_lossy().to_string(),
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
tags,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.needs_reload = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the user presets directory ($XDG_DATA_HOME/lightningbeam/presets or ~/.local/share/lightningbeam/presets)
|
||||||
|
fn user_presets_dir() -> PathBuf {
|
||||||
|
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||||
|
PathBuf::from(xdg).join("lightningbeam").join("presets")
|
||||||
|
} else if let Ok(home) = std::env::var("HOME") {
|
||||||
|
PathBuf::from(home).join(".local/share/lightningbeam/presets")
|
||||||
|
} else {
|
||||||
|
PathBuf::from("presets")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize a string for use as a filename
|
||||||
|
fn sanitize_filename(name: &str) -> String {
|
||||||
|
name.chars()
|
||||||
|
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' { c } else { '_' })
|
||||||
|
.collect::<String>()
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
impl PaneRenderer for PresetBrowserPane {
|
impl PaneRenderer for PresetBrowserPane {
|
||||||
|
fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool {
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
let has_track = shared.active_layer_id
|
||||||
|
.and_then(|lid| shared.layer_to_track_map.get(&lid))
|
||||||
|
.is_some();
|
||||||
|
if ui.add_enabled(has_track, egui::Button::new("Save")).clicked() {
|
||||||
|
self.save_dialog = Some(SaveDialogState::default());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn render_content(
|
fn render_content(
|
||||||
&mut self,
|
&mut self,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
rect: egui::Rect,
|
rect: egui::Rect,
|
||||||
_path: &NodePath,
|
_path: &NodePath,
|
||||||
_shared: &mut SharedPaneState,
|
shared: &mut SharedPaneState,
|
||||||
) {
|
) {
|
||||||
// Placeholder rendering
|
if self.needs_reload {
|
||||||
ui.painter().rect_filled(
|
self.scan_presets();
|
||||||
rect,
|
}
|
||||||
0.0,
|
|
||||||
egui::Color32::from_rgb(50, 45, 30),
|
|
||||||
);
|
|
||||||
|
|
||||||
let text = "Preset Browser\n(TODO: Implement file browser)";
|
// Background
|
||||||
ui.painter().text(
|
let bg_style = shared.theme.style(".pane-content", ui.ctx());
|
||||||
rect.center(),
|
let bg_color = bg_style.background_color.unwrap_or(egui::Color32::from_rgb(47, 47, 47));
|
||||||
egui::Align2::CENTER_CENTER,
|
ui.painter().rect_filled(rect, 0.0, bg_color);
|
||||||
text,
|
|
||||||
egui::FontId::proportional(16.0),
|
let text_color = shared.theme.style(".text-primary", ui.ctx())
|
||||||
egui::Color32::from_gray(150),
|
.text_color.unwrap_or(egui::Color32::from_gray(246));
|
||||||
|
let text_secondary = shared.theme.style(".text-secondary", ui.ctx())
|
||||||
|
.text_color.unwrap_or(egui::Color32::from_gray(170));
|
||||||
|
|
||||||
|
let content_rect = rect.shrink(4.0);
|
||||||
|
let mut content_ui = ui.new_child(
|
||||||
|
egui::UiBuilder::new()
|
||||||
|
.max_rect(content_rect)
|
||||||
|
.layout(egui::Layout::top_down(egui::Align::LEFT)),
|
||||||
);
|
);
|
||||||
|
let ui = &mut content_ui;
|
||||||
|
|
||||||
|
// Save dialog takes over the content area
|
||||||
|
if self.save_dialog.is_some() {
|
||||||
|
self.render_save_dialog(ui, shared);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search bar
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Search:");
|
||||||
|
ui.text_edit_singleline(&mut self.search_query);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
// Category chips
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
let all_selected = self.selected_category.is_none();
|
||||||
|
if ui.selectable_label(all_selected, "All").clicked() {
|
||||||
|
self.selected_category = None;
|
||||||
|
self.selected_index = None;
|
||||||
|
}
|
||||||
|
for cat in &self.categories.clone() {
|
||||||
|
let is_selected = self.selected_category.as_ref() == Some(cat);
|
||||||
|
let display = capitalize_first(cat);
|
||||||
|
if ui.selectable_label(is_selected, &display).clicked() {
|
||||||
|
if is_selected {
|
||||||
|
self.selected_category = None;
|
||||||
|
} else {
|
||||||
|
self.selected_category = Some(cat.clone());
|
||||||
|
}
|
||||||
|
self.selected_index = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// Preset list
|
||||||
|
let filtered = self.filtered_indices();
|
||||||
|
|
||||||
|
if filtered.is_empty() {
|
||||||
|
ui.centered_and_justified(|ui| {
|
||||||
|
ui.label(egui::RichText::new("No presets found")
|
||||||
|
.color(text_secondary));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut load_index = None;
|
||||||
|
let mut delete_path = None;
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||||
|
let mut new_selection = self.selected_index;
|
||||||
|
|
||||||
|
for &idx in &filtered {
|
||||||
|
let preset = &self.presets[idx];
|
||||||
|
let is_selected = self.selected_index == Some(idx);
|
||||||
|
|
||||||
|
let response = ui.push_id(idx, |ui| {
|
||||||
|
let frame = egui::Frame::NONE
|
||||||
|
.inner_margin(egui::Margin::same(6))
|
||||||
|
.corner_radius(4.0);
|
||||||
|
|
||||||
|
let mut button_clicked = false;
|
||||||
|
|
||||||
|
let frame_response = frame.show(ui, |ui| {
|
||||||
|
ui.set_min_width(ui.available_width());
|
||||||
|
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(&preset.name).strong().color(text_color)
|
||||||
|
);
|
||||||
|
|
||||||
|
if is_selected {
|
||||||
|
if !preset.description.is_empty() {
|
||||||
|
ui.label(egui::RichText::new(&preset.description)
|
||||||
|
.color(text_secondary)
|
||||||
|
.small());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !preset.tags.is_empty() {
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
for tag in &preset.tags {
|
||||||
|
let tag_frame = egui::Frame::NONE
|
||||||
|
.inner_margin(egui::Margin::symmetric(6, 2))
|
||||||
|
.corner_radius(8.0)
|
||||||
|
.fill(ui.visuals().selection.bg_fill.linear_multiply(0.3));
|
||||||
|
tag_frame.show(ui, |ui| {
|
||||||
|
ui.label(egui::RichText::new(tag).small().color(text_color));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if !preset.author.is_empty() {
|
||||||
|
ui.label(egui::RichText::new(format!("by {}", preset.author))
|
||||||
|
.small()
|
||||||
|
.color(text_secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if !preset.is_factory {
|
||||||
|
if ui.small_button("Delete").clicked() {
|
||||||
|
delete_path = Some(preset.path.clone());
|
||||||
|
button_clicked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_track = shared.active_layer_id
|
||||||
|
.and_then(|lid| shared.layer_to_track_map.get(&lid))
|
||||||
|
.is_some();
|
||||||
|
if ui.add_enabled(has_track, egui::Button::new("Load")).clicked() {
|
||||||
|
load_index = Some(idx);
|
||||||
|
button_clicked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hover highlight and click-to-select (no ui.interact overlay)
|
||||||
|
let frame_rect = frame_response.response.rect;
|
||||||
|
let is_hovered = ui.rect_contains_pointer(frame_rect);
|
||||||
|
|
||||||
|
let fill = if is_selected {
|
||||||
|
ui.visuals().selection.bg_fill.linear_multiply(0.3)
|
||||||
|
} else if is_hovered {
|
||||||
|
ui.visuals().widgets.hovered.bg_fill.linear_multiply(0.3)
|
||||||
|
} else {
|
||||||
|
egui::Color32::TRANSPARENT
|
||||||
|
};
|
||||||
|
if fill != egui::Color32::TRANSPARENT {
|
||||||
|
ui.painter().rect_filled(frame_rect, 4.0, fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_hovered {
|
||||||
|
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||||
|
}
|
||||||
|
if is_hovered && !button_clicked && ui.input(|i| i.pointer.any_released()) {
|
||||||
|
new_selection = if is_selected { None } else { Some(idx) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let rect = response.response.rect;
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[rect.left_bottom(), rect.right_bottom()],
|
||||||
|
egui::Stroke::new(0.5, ui.visuals().widgets.noninteractive.bg_stroke.color),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.selected_index = new_selection;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deferred actions after ScrollArea borrow is released
|
||||||
|
if let Some(idx) = load_index {
|
||||||
|
self.load_preset(idx, shared);
|
||||||
|
}
|
||||||
|
if let Some(path) = delete_path {
|
||||||
|
if let Err(e) = std::fs::remove_file(&path) {
|
||||||
|
eprintln!("Failed to delete preset: {e}");
|
||||||
|
}
|
||||||
|
self.needs_reload = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"Preset Browser"
|
"Instrument Browser"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capitalize_first(s: &str) -> String {
|
||||||
|
let mut chars = s.chars();
|
||||||
|
match chars.next() {
|
||||||
|
None => String::new(),
|
||||||
|
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -758,13 +758,24 @@ impl PaneRenderer for VirtualPianoPane {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request keyboard focus to prevent tool shortcuts from firing
|
// Request keyboard focus to prevent tool shortcuts from firing,
|
||||||
// This sets wants_keyboard_input() to true
|
// but yield to text input widgets (node finder search, group rename, etc.)
|
||||||
let piano_id = ui.id().with("virtual_piano_keyboard");
|
let piano_id = ui.id().with("virtual_piano_keyboard");
|
||||||
ui.memory_mut(|m| m.request_focus(piano_id));
|
let other_has_focus = ui.memory(|m| {
|
||||||
|
m.focused().map_or(false, |id| id != piano_id)
|
||||||
|
});
|
||||||
|
if !other_has_focus {
|
||||||
|
ui.memory_mut(|m| m.request_focus(piano_id));
|
||||||
|
}
|
||||||
|
|
||||||
// Handle keyboard input FIRST
|
// Handle keyboard input (skip when a text field has focus)
|
||||||
self.handle_keyboard_input(ui, shared);
|
if other_has_focus {
|
||||||
|
if !self.active_key_presses.is_empty() {
|
||||||
|
self.release_all_keyboard_notes(shared);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.handle_keyboard_input(ui, shared);
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate visible range (needed for both rendering and labels)
|
// Calculate visible range (needed for both rendering and labels)
|
||||||
let (visible_start, visible_end, white_key_width, offset_x) =
|
let (visible_start, visible_end, white_key_width, offset_x) =
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,139 @@
|
||||||
{
|
{
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"name": "Bright Lead",
|
"name": "Bright Lead",
|
||||||
"description": "Piercing lead synth with filter modulation",
|
"description": "Piercing lead synth with filter modulation (polyphonic)",
|
||||||
"author": "Lightningbeam",
|
"author": "Lightningbeam",
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"tags": ["lead", "synth", "solo"]
|
"tags": ["lead", "synth", "solo"]
|
||||||
},
|
},
|
||||||
"midi_targets": [0],
|
"midi_targets": [0],
|
||||||
"output_node": 7,
|
"output_node": 2,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": 0,
|
"id": 0,
|
||||||
"node_type": "MidiInput",
|
"node_type": "MidiInput",
|
||||||
"name": "MIDI In",
|
"name": "MIDI In",
|
||||||
"parameters": {},
|
"parameters": {},
|
||||||
"position": [100.0, 100.0]
|
"position": [100.0, 150.0]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"node_type": "MidiToCV",
|
"node_type": "VoiceAllocator",
|
||||||
"name": "MIDI→CV",
|
"name": "Voice Allocator",
|
||||||
"parameters": {},
|
"parameters": {
|
||||||
"position": [400.0, 100.0]
|
"0": 8.0
|
||||||
|
},
|
||||||
|
"position": [400.0, 150.0],
|
||||||
|
"template_graph": {
|
||||||
|
"metadata": {
|
||||||
|
"name": "Voice Template",
|
||||||
|
"description": "Per-voice lead synth patch",
|
||||||
|
"author": "Lightningbeam",
|
||||||
|
"version": 1,
|
||||||
|
"tags": []
|
||||||
|
},
|
||||||
|
"midi_targets": [0],
|
||||||
|
"output_node": 7,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"node_type": "TemplateInput",
|
||||||
|
"name": "Template Input",
|
||||||
|
"parameters": {},
|
||||||
|
"position": [-200.0, 0.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"node_type": "MidiToCV",
|
||||||
|
"name": "MIDI→CV",
|
||||||
|
"parameters": {},
|
||||||
|
"position": [100.0, 0.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"node_type": "Oscillator",
|
||||||
|
"name": "Osc",
|
||||||
|
"parameters": {
|
||||||
|
"0": 440.0,
|
||||||
|
"1": 0.6,
|
||||||
|
"2": 2.0
|
||||||
|
},
|
||||||
|
"position": [400.0, -100.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"node_type": "LFO",
|
||||||
|
"name": "Filter Mod",
|
||||||
|
"parameters": {
|
||||||
|
"0": 5.0,
|
||||||
|
"1": 0.5,
|
||||||
|
"2": 0.0,
|
||||||
|
"3": 0.0
|
||||||
|
},
|
||||||
|
"position": [400.0, 200.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"node_type": "Filter",
|
||||||
|
"name": "LP Filter",
|
||||||
|
"parameters": {
|
||||||
|
"0": 2000.0,
|
||||||
|
"1": 2.0,
|
||||||
|
"2": 0.0
|
||||||
|
},
|
||||||
|
"position": [700.0, -80.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"node_type": "ADSR",
|
||||||
|
"name": "Amp Env",
|
||||||
|
"parameters": {
|
||||||
|
"0": 0.01,
|
||||||
|
"1": 0.1,
|
||||||
|
"2": 0.6,
|
||||||
|
"3": 0.2
|
||||||
|
},
|
||||||
|
"position": [700.0, 200.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"node_type": "Gain",
|
||||||
|
"name": "VCA",
|
||||||
|
"parameters": {
|
||||||
|
"0": 1.0
|
||||||
|
},
|
||||||
|
"position": [1000.0, 50.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"node_type": "TemplateOutput",
|
||||||
|
"name": "Template Output",
|
||||||
|
"parameters": {},
|
||||||
|
"position": [1200.0, 50.0]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": [
|
||||||
|
{ "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 },
|
||||||
|
{ "from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0 },
|
||||||
|
{ "from_node": 1, "from_port": 1, "to_node": 5, "to_port": 0 },
|
||||||
|
{ "from_node": 2, "from_port": 0, "to_node": 4, "to_port": 0 },
|
||||||
|
{ "from_node": 3, "from_port": 0, "to_node": 4, "to_port": 1 },
|
||||||
|
{ "from_node": 4, "from_port": 0, "to_node": 6, "to_port": 0 },
|
||||||
|
{ "from_node": 5, "from_port": 0, "to_node": 6, "to_port": 1 },
|
||||||
|
{ "from_node": 6, "from_port": 0, "to_node": 7, "to_port": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"node_type": "Oscillator",
|
|
||||||
"name": "Osc",
|
|
||||||
"parameters": {
|
|
||||||
"0": 440.0,
|
|
||||||
"1": 0.6,
|
|
||||||
"2": 2.0
|
|
||||||
},
|
|
||||||
"position": [700.0, -100.0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"node_type": "LFO",
|
|
||||||
"name": "Filter Mod",
|
|
||||||
"parameters": {
|
|
||||||
"0": 5.0,
|
|
||||||
"1": 0.5,
|
|
||||||
"2": 0.0,
|
|
||||||
"3": 0.0
|
|
||||||
},
|
|
||||||
"position": [700.0, 200.0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"node_type": "Filter",
|
|
||||||
"name": "LP Filter",
|
|
||||||
"parameters": {
|
|
||||||
"0": 2000.0,
|
|
||||||
"1": 2.0,
|
|
||||||
"2": 0.0
|
|
||||||
},
|
|
||||||
"position": [1000.0, -80.0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"node_type": "ADSR",
|
|
||||||
"name": "Amp Env",
|
|
||||||
"parameters": {
|
|
||||||
"0": 0.01,
|
|
||||||
"1": 0.1,
|
|
||||||
"2": 0.6,
|
|
||||||
"3": 0.2
|
|
||||||
},
|
|
||||||
"position": [1000.0, 240.0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 6,
|
|
||||||
"node_type": "Gain",
|
|
||||||
"name": "VCA",
|
|
||||||
"parameters": {
|
|
||||||
"0": 1.0
|
|
||||||
},
|
|
||||||
"position": [1300.0, 150.0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 7,
|
|
||||||
"node_type": "AudioOutput",
|
"node_type": "AudioOutput",
|
||||||
"name": "Out",
|
"name": "Out",
|
||||||
"parameters": {},
|
"parameters": {},
|
||||||
"position": [1600.0, 150.0]
|
"position": [700.0, 150.0]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"connections": [
|
"connections": [
|
||||||
{ "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 },
|
{ "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 },
|
||||||
{ "from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0 },
|
{ "from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0 }
|
||||||
{ "from_node": 1, "from_port": 1, "to_node": 5, "to_port": 0 },
|
|
||||||
{ "from_node": 2, "from_port": 0, "to_node": 4, "to_port": 0 },
|
|
||||||
{ "from_node": 3, "from_port": 0, "to_node": 4, "to_port": 1 },
|
|
||||||
{ "from_node": 4, "from_port": 0, "to_node": 6, "to_port": 0 },
|
|
||||||
{ "from_node": 5, "from_port": 0, "to_node": 6, "to_port": 1 },
|
|
||||||
{ "from_node": 6, "from_port": 0, "to_node": 7, "to_port": 0 }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
{
|
{
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"name": "Lush Pad",
|
"name": "Lush Pad",
|
||||||
"description": "Ambient pad with reverb and chorus",
|
"description": "Ambient pad with reverb and chorus (polyphonic)",
|
||||||
"author": "Lightningbeam",
|
"author": "Lightningbeam",
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"tags": ["pad", "ambient", "synth"]
|
"tags": ["pad", "ambient", "synth"]
|
||||||
},
|
},
|
||||||
"midi_targets": [0],
|
"midi_targets": [0],
|
||||||
"output_node": 10,
|
"output_node": 4,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": 0,
|
"id": 0,
|
||||||
|
|
@ -18,79 +18,127 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"node_type": "MidiToCV",
|
"node_type": "VoiceAllocator",
|
||||||
"name": "MIDI→CV",
|
"name": "Voice Allocator",
|
||||||
"parameters": {},
|
"parameters": {
|
||||||
"position": [400.0, 150.0]
|
"0": 8.0
|
||||||
|
},
|
||||||
|
"position": [400.0, 150.0],
|
||||||
|
"template_graph": {
|
||||||
|
"metadata": {
|
||||||
|
"name": "Voice Template",
|
||||||
|
"description": "Per-voice pad patch",
|
||||||
|
"author": "Lightningbeam",
|
||||||
|
"version": 1,
|
||||||
|
"tags": []
|
||||||
|
},
|
||||||
|
"midi_targets": [0],
|
||||||
|
"output_node": 8,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"node_type": "TemplateInput",
|
||||||
|
"name": "Template Input",
|
||||||
|
"parameters": {},
|
||||||
|
"position": [-200.0, 0.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"node_type": "MidiToCV",
|
||||||
|
"name": "MIDI→CV",
|
||||||
|
"parameters": {},
|
||||||
|
"position": [100.0, 0.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"node_type": "Oscillator",
|
||||||
|
"name": "Osc 1",
|
||||||
|
"parameters": {
|
||||||
|
"0": 440.0,
|
||||||
|
"1": 0.4,
|
||||||
|
"2": 0.0
|
||||||
|
},
|
||||||
|
"position": [400.0, -100.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"node_type": "Oscillator",
|
||||||
|
"name": "Osc 2",
|
||||||
|
"parameters": {
|
||||||
|
"0": 442.0,
|
||||||
|
"1": 0.4,
|
||||||
|
"2": 0.0
|
||||||
|
},
|
||||||
|
"position": [400.0, 200.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"node_type": "Mixer",
|
||||||
|
"name": "Osc Mix",
|
||||||
|
"parameters": {
|
||||||
|
"0": 1.0,
|
||||||
|
"1": 1.0,
|
||||||
|
"2": 0.0,
|
||||||
|
"3": 0.0
|
||||||
|
},
|
||||||
|
"position": [700.0, 50.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"node_type": "Filter",
|
||||||
|
"name": "LP Filter",
|
||||||
|
"parameters": {
|
||||||
|
"0": 1500.0,
|
||||||
|
"1": 0.707,
|
||||||
|
"2": 0.0
|
||||||
|
},
|
||||||
|
"position": [900.0, -50.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"node_type": "ADSR",
|
||||||
|
"name": "Amp Env",
|
||||||
|
"parameters": {
|
||||||
|
"0": 0.5,
|
||||||
|
"1": 0.3,
|
||||||
|
"2": 0.7,
|
||||||
|
"3": 1.0
|
||||||
|
},
|
||||||
|
"position": [900.0, 200.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"node_type": "Gain",
|
||||||
|
"name": "VCA",
|
||||||
|
"parameters": {
|
||||||
|
"0": 1.0
|
||||||
|
},
|
||||||
|
"position": [1100.0, 50.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"node_type": "TemplateOutput",
|
||||||
|
"name": "Template Output",
|
||||||
|
"parameters": {},
|
||||||
|
"position": [1300.0, 50.0]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": [
|
||||||
|
{ "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 },
|
||||||
|
{ "from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0 },
|
||||||
|
{ "from_node": 1, "from_port": 0, "to_node": 3, "to_port": 0 },
|
||||||
|
{ "from_node": 1, "from_port": 1, "to_node": 6, "to_port": 0 },
|
||||||
|
{ "from_node": 2, "from_port": 0, "to_node": 4, "to_port": 0 },
|
||||||
|
{ "from_node": 3, "from_port": 0, "to_node": 4, "to_port": 1 },
|
||||||
|
{ "from_node": 4, "from_port": 0, "to_node": 5, "to_port": 0 },
|
||||||
|
{ "from_node": 5, "from_port": 0, "to_node": 7, "to_port": 0 },
|
||||||
|
{ "from_node": 6, "from_port": 0, "to_node": 7, "to_port": 1 },
|
||||||
|
{ "from_node": 7, "from_port": 0, "to_node": 8, "to_port": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"node_type": "Oscillator",
|
|
||||||
"name": "Osc 1",
|
|
||||||
"parameters": {
|
|
||||||
"0": 440.0,
|
|
||||||
"1": 0.4,
|
|
||||||
"2": 0.0
|
|
||||||
},
|
|
||||||
"position": [700.0, -100.0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"node_type": "Oscillator",
|
|
||||||
"name": "Osc 2",
|
|
||||||
"parameters": {
|
|
||||||
"0": 442.0,
|
|
||||||
"1": 0.4,
|
|
||||||
"2": 0.0
|
|
||||||
},
|
|
||||||
"position": [700.0, 200.0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"node_type": "Mixer",
|
|
||||||
"name": "Osc Mix",
|
|
||||||
"parameters": {
|
|
||||||
"0": 1.0,
|
|
||||||
"1": 1.0,
|
|
||||||
"2": 0.0,
|
|
||||||
"3": 0.0
|
|
||||||
},
|
|
||||||
"position": [1000.0, 150.0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"node_type": "Filter",
|
|
||||||
"name": "LP Filter",
|
|
||||||
"parameters": {
|
|
||||||
"0": 1500.0,
|
|
||||||
"1": 0.707,
|
|
||||||
"2": 0.0
|
|
||||||
},
|
|
||||||
"position": [1300.0, -50.0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 6,
|
|
||||||
"node_type": "ADSR",
|
|
||||||
"name": "Amp Env",
|
|
||||||
"parameters": {
|
|
||||||
"0": 0.5,
|
|
||||||
"1": 0.3,
|
|
||||||
"2": 0.7,
|
|
||||||
"3": 1.0
|
|
||||||
},
|
|
||||||
"position": [1300.0, 280.0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 7,
|
|
||||||
"node_type": "Gain",
|
|
||||||
"name": "VCA",
|
|
||||||
"parameters": {
|
|
||||||
"0": 1.0
|
|
||||||
},
|
|
||||||
"position": [1600.0, 200.0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 8,
|
|
||||||
"node_type": "Chorus",
|
"node_type": "Chorus",
|
||||||
"name": "Chorus",
|
"name": "Chorus",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|
@ -98,10 +146,10 @@
|
||||||
"1": 0.6,
|
"1": 0.6,
|
||||||
"2": 0.4
|
"2": 0.4
|
||||||
},
|
},
|
||||||
"position": [1900.0, 200.0]
|
"position": [700.0, 150.0]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 9,
|
"id": 3,
|
||||||
"node_type": "Reverb",
|
"node_type": "Reverb",
|
||||||
"name": "Reverb",
|
"name": "Reverb",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|
@ -109,28 +157,20 @@
|
||||||
"1": 0.5,
|
"1": 0.5,
|
||||||
"2": 0.5
|
"2": 0.5
|
||||||
},
|
},
|
||||||
"position": [2200.0, 200.0]
|
"position": [1000.0, 150.0]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 10,
|
"id": 4,
|
||||||
"node_type": "AudioOutput",
|
"node_type": "AudioOutput",
|
||||||
"name": "Out",
|
"name": "Out",
|
||||||
"parameters": {},
|
"parameters": {},
|
||||||
"position": [2500.0, 200.0]
|
"position": [1300.0, 150.0]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"connections": [
|
"connections": [
|
||||||
{ "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 },
|
{ "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 },
|
||||||
{ "from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0 },
|
{ "from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0 },
|
||||||
{ "from_node": 1, "from_port": 0, "to_node": 3, "to_port": 0 },
|
{ "from_node": 2, "from_port": 0, "to_node": 3, "to_port": 0 },
|
||||||
{ "from_node": 1, "from_port": 1, "to_node": 6, "to_port": 0 },
|
{ "from_node": 3, "from_port": 0, "to_node": 4, "to_port": 0 }
|
||||||
{ "from_node": 2, "from_port": 0, "to_node": 4, "to_port": 0 },
|
|
||||||
{ "from_node": 3, "from_port": 0, "to_node": 4, "to_port": 1 },
|
|
||||||
{ "from_node": 4, "from_port": 0, "to_node": 5, "to_port": 0 },
|
|
||||||
{ "from_node": 5, "from_port": 0, "to_node": 7, "to_port": 0 },
|
|
||||||
{ "from_node": 6, "from_port": 0, "to_node": 7, "to_port": 1 },
|
|
||||||
{ "from_node": 7, "from_port": 0, "to_node": 8, "to_port": 0 },
|
|
||||||
{ "from_node": 8, "from_port": 0, "to_node": 9, "to_port": 0 },
|
|
||||||
{ "from_node": 9, "from_port": 0, "to_node": 10, "to_port": 0 }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue